From 8708d2833b7033e6a7d44fe3e741f61b5d8fc8e4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 08:57:36 +0100 Subject: [PATCH 01/17] feat(status-pages): page config document, applied atomically by the service StatusPageConfig describes a page's settings, theme and components as one document. applyConfig replaces the page in one transaction with one revision bump: listed ids update, new entries are created, missing ones are deleted, order sets position. getConfig reads it back. --- .../status-pages/StatusPagesService.test.ts | 211 +++++++++++++++++ .../status-pages/StatusPagesService.ts | 221 ++++++++++++++++++ .../src/http/v2/status-page-config.test.ts | 114 +++++++++ packages/domain/src/http/v2/status-pages.ts | 188 +++++++++++++++ 4 files changed, 734 insertions(+) create mode 100644 packages/domain/src/http/v2/status-page-config.test.ts diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 24b444f143..873f0f716d 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -2,6 +2,7 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Effect, Layer, Schema } from "effect" import { TestClock } from "effect/testing" import { OrgId, UserId } from "@maple/domain/http" +import { StatusPageComponentPublicId, type StatusPageConfig } from "@maple/domain/http/v2" import { StatusPageComponentId, StatusPageId, UptimeMonitorId } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, @@ -539,4 +540,214 @@ describe("StatusPagesService", () => { assert.strictEqual(yield* revision(testDb, page.id), before) }).pipe(Effect.provide(makeLayer(testDb))) }) + + describe("config", () => { + const cleanupSlugs = (testDb: TestDb) => + Effect.promise(() => + testDb.pglite.query<{ slug: string }>( + `SELECT slug FROM status_page_cleanups WHERE delete_snapshot = false`, + ), + ).pipe(Effect.map((result) => result.rows.map((row) => row.slug))) + + const seeded = (testDb: TestDb) => + Effect.gen(function* () { + const service = yield* StatusPagesService + yield* seedMonitor(testDb, ORG, MONITOR) + yield* seedMonitor(testDb, OTHER_ORG, OTHER_MONITOR) + const page = yield* createPage("config") + const api = yield* service.createComponent(ORG, page.id, { name: "API", monitorId: MONITOR }) + const web = yield* service.createComponent(ORG, page.id, { name: "Web" }) + const docs = yield* service.createComponent(ORG, page.id, { name: "Docs" }) + return { service, page, api, web, docs, config: yield* service.getConfig(ORG, page.id) } + }) + + it.effect("reads the page and its components in order as one document", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { page, api, web, docs, config } = yield* seeded(testDb) + assert.deepStrictEqual(config, { + name: page.name, + slug: "config", + description: null, + website_url: null, + logo_url: null, + show_powered_by: true, + auto_incidents: "off", + theme: DEFAULT_STATUS_PAGE_THEME, + components: [api, web, docs].map((component) => ({ + id: component.id, + name: component.name, + description: null, + group: null, + monitor_id: component.monitorId, + manual_status: null, + show_uptime: true, + })), + }) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect( + "creates, updates, deletes and reorders components, with defaults for omitted fields", + () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, api, web, docs } = yield* seeded(testDb) + yield* service.update(ORG, page.id, { description: "Old", showPoweredBy: false }) + const before = yield* revision(testDb, page.id) + const applied = yield* service.applyConfig( + ORG, + page.id, + { + name: "Acme", + slug: "config", + theme: { preset: "paper" }, + components: [ + { id: docs.id, name: "Docs site", group: "Web" }, + { name: "Status API", monitor_id: MONITOR, show_uptime: false }, + { id: api.id, name: "API", monitor_id: MONITOR }, + ], + }, + USER, + ) + assert.strictEqual(yield* revision(testDb, page.id), before + 1) + const pageNow = yield* service.get(ORG, page.id) + assert.strictEqual(pageNow.name, "Acme") + assert.isNull(pageNow.description) + assert.isTrue(pageNow.showPoweredBy) + assert.deepStrictEqual(pageNow.theme, { ...DEFAULT_STATUS_PAGE_THEME, preset: "paper" }) + + const listed = yield* service.listComponents(ORG, page.id) + assert.deepStrictEqual( + listed.map((row) => [ + row.name, + row.position, + row.groupName, + row.monitorId, + row.showUptime, + ]), + [ + ["Docs site", 0, "Web", null, true], + ["Status API", 1, null, MONITOR, false], + ["API", 2, null, MONITOR, true], + ], + ) + assert.strictEqual(listed[0]?.id, docs.id) + assert.strictEqual(listed[2]?.id, api.id) + assert.isFalse(listed.some((row) => row.id === web.id)) + assert.deepStrictEqual( + applied.components.map((component) => component.id), + listed.map((row) => row.id), + ) + assert.deepStrictEqual(applied, yield* service.getConfig(ORG, page.id)) + }).pipe(Effect.provide(makeLayer(testDb))) + }, + ) + + it.effect("re-applying the current document bumps the revision once and changes nothing", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const before = yield* revision(testDb, page.id) + assert.deepStrictEqual(yield* service.applyConfig(ORG, page.id, config, USER), config) + assert.strictEqual(yield* revision(testDb, page.id), before + 1) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect( + "refuses ids of another page, duplicates, foreign monitors and bad themes, changing nothing", + () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const other = yield* createPage("config-other") + const elsewhere = yield* service.createComponent(ORG, other.id, { name: "Elsewhere" }) + const foreignOrgPage = yield* createPage("config-foreign", OTHER_ORG) + const foreignOrg = yield* service.createComponent(OTHER_ORG, foreignOrgPage.id, { + name: "X", + }) + const before = yield* revision(testDb, page.id) + const first = config.components[0] + assert.isDefined(first) + const withRenamedPage = { ...config, name: "Renamed" } + const cases: ReadonlyArray = [ + [ + { + ...withRenamedPage, + components: [...config.components, { id: elsewhere.id, name: "E" }], + }, + `Component ${Schema.encodeSync(StatusPageComponentPublicId)(elsewhere.id)} is not on this page`, + ], + [ + { ...withRenamedPage, components: [{ id: foreignOrg.id, name: "X" }] }, + `Component ${Schema.encodeSync(StatusPageComponentPublicId)(foreignOrg.id)} is not on this page`, + ], + [ + { + ...withRenamedPage, + components: [...config.components, { ...first, name: "Twice" }], + }, + "is listed more than once", + ], + [ + { + ...withRenamedPage, + components: [{ name: "Foreign", monitor_id: OTHER_MONITOR }], + }, + "Unknown uptime monitor upm_", + ], + [{ ...withRenamedPage, theme: { appearance: "light", text: "#777777" } }, "4.48:1"], + [ + { ...withRenamedPage, components: [{ name: " " }] }, + "components[0].name is required", + ], + ] + yield* Effect.forEach(cases, ([input, message]) => + Effect.flip(service.applyConfig(ORG, page.id, input, USER)).pipe( + Effect.map((error) => { + assert.strictEqual(error._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(error.message, message) + }), + ), + ) + assert.strictEqual(yield* revision(testDb, page.id), before) + assert.deepStrictEqual(yield* service.getConfig(ORG, page.id), config) + assert.strictEqual((yield* service.listComponents(ORG, other.id)).length, 1) + }).pipe(Effect.provide(makeLayer(testDb))) + }, + ) + + it.effect("queues the old slug for cleanup on a rename and refuses a taken slug", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + yield* createPage("taken-elsewhere", OTHER_ORG) + const taken = yield* Effect.flip( + service.applyConfig(ORG, page.id, { ...config, slug: "taken-elsewhere" }, USER), + ) + assert.strictEqual(taken._tag, "@maple/http/errors/StatusPageSlugTakenError") + assert.deepStrictEqual(yield* cleanupSlugs(testDb), []) + + const renamed = yield* service.applyConfig( + ORG, + page.id, + { ...config, slug: "config-new" }, + USER, + ) + assert.strictEqual(renamed.slug, "config-new") + assert.deepStrictEqual(yield* cleanupSlugs(testDb), ["config"]) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("a missing or cross-org page is not found", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const read = yield* Effect.flip(service.getConfig(OTHER_ORG, page.id)) + assert.strictEqual(read._tag, "@maple/http/errors/StatusPageNotFoundError") + const write = yield* Effect.flip(service.applyConfig(OTHER_ORG, page.id, config, USER)) + assert.strictEqual(write._tag, "@maple/http/errors/StatusPageNotFoundError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + }) }) diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index 52ce61c66a..6b2a210cc8 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -25,6 +25,12 @@ import { type StatusPageIncidentUpdateRecord, type StatusPageRecord, } from "@maple/domain/http" +import { + normalizeStatusPageConfig, + StatusPageComponentPublicId, + type StatusPageConfig, + UptimeMonitorPublicId, +} from "@maple/domain/http/v2" import { StatusPageComponentId, StatusPageId, @@ -220,6 +226,15 @@ export interface StatusPagesServiceApi { orgId: OrgId, incidentId: StatusPageIncidentId, ) => Effect.Effect + /** The page's settings, theme and components as one document. */ + readonly getConfig: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect + /** Replaces the page with `config` in one transaction: all of it applies, or none of it. */ + readonly applyConfig: ( + orgId: OrgId, + pageId: StatusPageId, + config: StatusPageConfig, + actor: UserId, + ) => Effect.Effect } const MAX_NAME_LENGTH = 120 @@ -548,6 +563,67 @@ const replaceIncidentComponents = Effect.fnUntraced(function* ( ) }) +const encodeComponentId = Schema.encodeSync(StatusPageComponentPublicId) +const encodeMonitorId = Schema.encodeSync(UptimeMonitorPublicId) + +/** Every monitor must belong to the org; the error names the first that does not by its public id. */ +const ensureMonitorsInOrg = Effect.fnUntraced(function* ( + db: MapleDb, + orgId: OrgId, + monitorIds: ReadonlyArray, +) { + const [first, ...rest] = monitorIds + if (first === undefined) return + const found = yield* db.run( + PG.from(UptimeMonitors) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(first, ...rest)]), + ) + const known = new Set(found.map((row) => row.id)) + const missing = monitorIds.find((id) => !known.has(id)) + if (missing !== undefined) return yield* invalid(`Unknown uptime monitor ${encodeMonitorId(missing)}`) +}) + +const toConfig = ( + page: StatusPageRow, + components: ReadonlyArray, +): StatusPageConfig => ({ + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.websiteUrl, + logo_url: page.logoUrl, + show_powered_by: page.showPoweredBy, + auto_incidents: page.autoIncidents, + theme: page.theme, + components: components.map((component) => ({ + id: component.id, + name: component.name, + description: component.description, + group: component.groupName, + monitor_id: component.monitorId, + manual_status: component.manualStatus, + show_uptime: component.showUptime, + })), +}) + +/** One config component, validated and with its defaults, before any id is checked. */ +const validateConfigComponent = Effect.fnUntraced(function* ( + component: StatusPageConfig["components"][number], + index: number, +) { + const at = `components[${index}]` + return { + id: component.id ?? null, + name: yield* validateName(component.name, `${at}.name`), + description: yield* validateText(component.description ?? null, `${at}.description`), + groupName: yield* validateText(component.group ?? null, `${at}.group`), + monitorId: component.monitor_id ?? null, + manualStatus: component.manual_status ?? null, + showUptime: component.show_uptime ?? true, + } +}) + const newBreakGlassToken = () => `${STATUS_PAGE_BREAK_GLASS_PREFIX}${randomBytes(32).toString("base64url")}` /** Newest incidents a list returns: with history included, a long-lived page stays bounded. */ @@ -1243,7 +1319,152 @@ export class StatusPagesService extends Context.Service + Effect.gen(function* () { + const page = yield* findPage(db, orgId, pageId) + return toConfig(page, yield* selectComponents(db, orgId, pageId)) + }), + ) + }) + + const applyConfig = Effect.fn("StatusPagesService.applyConfig")(function* ( + orgId: OrgId, + pageId: StatusPageId, + config: StatusPageConfig, + actor: UserId, + ) { + yield* Effect.annotateCurrentSpan({ + orgId, + "maple.status_page.id": pageId, + "maple.status_page.actor": actor, + "maple.status_page.config_components": config.components.length, + }) + const input = normalizeStatusPageConfig(config) + const name = yield* validateName(input.name, "Name") + const slug = yield* validateSlug(input.slug) + const description = yield* validateText(input.description, "Description") + const websiteUrl = yield* validateHttpsUrl(input.website_url, "Website URL") + const logoUrl = yield* validateHttpsUrl(input.logo_url, "Logo URL") + const theme = yield* validateTheme(input.theme) + const components = yield* Effect.forEach(input.components, validateConfigComponent) + const listedIds = components.flatMap((component) => + component.id === null ? [] : [component.id], + ) + const duplicate = listedIds.find((id, index) => listedIds.indexOf(id) !== index) + if (duplicate !== undefined) + return yield* invalid( + `Component ${encodeComponentId(duplicate)} is listed more than once`, + ) + const monitorIds = [ + ...new Set(components.flatMap((c) => (c.monitorId === null ? [] : [c.monitorId]))), + ] + const now = yield* Clock.currentTimeMillis + return yield* dbExecuteSlug(slug)((db) => + db.transaction( + Effect.gen(function* () { + const current = yield* findPage(db, orgId, pageId) + const existing = yield* selectComponents(db, orgId, pageId) + const byId = new Map(existing.map((row) => [row.id, row])) + const foreign = listedIds.find((id) => !byId.has(id)) + if (foreign !== undefined) + return yield* invalid( + `Component ${encodeComponentId(foreign)} is not on this page`, + ) + yield* ensureMonitorsInOrg(db, orgId, monitorIds) + yield* ensureSlugFree(db, slug, pageId) + // The old slug route stays at the edge until the publisher drains this. + if (slug !== current.slug) + yield* queueSlugRemoval(db, orgId, pageId, current.slug) + yield* db.run( + PG.update(StatusPages) + .set({ + name, + slug, + description, + websiteUrl, + logoUrl, + theme, + showPoweredBy: input.show_powered_by, + autoIncidents: input.auto_incidents, + updatedAt: now, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(pageId)]), + ) + const listed = new Set(listedIds) + const [firstRemoved, ...restRemoved] = existing + .filter((row) => !listed.has(row.id)) + .map((row) => row.id) + if (firstRemoved !== undefined) + yield* db.run( + PG.deleteFrom(StatusPageComponents).where(($) => [ + $.orgId.eq(orgId), + $.pageId.eq(pageId), + $.id.in_(firstRemoved, ...restRemoved), + ]), + ) + yield* Effect.forEach( + components, + (component, position) => { + const fields = { + name: component.name, + description: component.description, + groupName: component.groupName, + position, + monitorId: component.monitorId, + manualStatus: component.manualStatus, + showUptime: component.showUptime, + } + const previous = + component.id === null ? undefined : byId.get(component.id) + if (previous === undefined) + return db.run( + PG.insertInto(StatusPageComponents).values({ + ...fields, + id: StatusPageComponentId.make(randomUUID()), + orgId, + pageId, + createdAt: now, + updatedAt: now, + }), + ) + const unchanged = + previous.name === fields.name && + previous.description === fields.description && + previous.groupName === fields.groupName && + previous.position === fields.position && + previous.monitorId === fields.monitorId && + previous.manualStatus === fields.manualStatus && + previous.showUptime === fields.showUptime + return unchanged + ? Effect.void + : db.run( + PG.update(StatusPageComponents) + .set({ ...fields, updatedAt: now }) + .where(($) => [ + $.orgId.eq(orgId), + $.pageId.eq(pageId), + $.id.eq(previous.id), + ]), + ) + }, + { discard: true }, + ) + yield* bumpPage(db, orgId, pageId) + const page = yield* findPage(db, orgId, pageId) + return toConfig(page, yield* selectComponents(db, orgId, pageId)) + }), + ), + ) + }) + return { + getConfig, + applyConfig, list, get, create, diff --git a/packages/domain/src/http/v2/status-page-config.test.ts b/packages/domain/src/http/v2/status-page-config.test.ts new file mode 100644 index 0000000000..b83e4800f6 --- /dev/null +++ b/packages/domain/src/http/v2/status-page-config.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { StatusPageComponentId } from "../../primitives" +import { DEFAULT_STATUS_PAGE_THEME } from "../../status-page" +import { diffStatusPageConfig, StatusPageConfig } from "./status-pages" + +const API = StatusPageComponentId.make("11111111-1111-4111-8111-111111111111") +const WEB = StatusPageComponentId.make("22222222-2222-4222-8222-222222222222") +const DOCS = StatusPageComponentId.make("33333333-3333-4333-8333-333333333333") +const STRAY = StatusPageComponentId.make("44444444-4444-4444-8444-444444444444") + +const current: StatusPageConfig = { + name: "Acme", + slug: "acme", + description: null, + website_url: null, + logo_url: null, + show_powered_by: true, + auto_incidents: "off", + theme: DEFAULT_STATUS_PAGE_THEME, + components: [ + { + id: API, + name: "API", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + { + id: WEB, + name: "Web", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + { + id: DOCS, + name: "Docs", + description: null, + group: null, + monitor_id: null, + manual_status: null, + show_uptime: true, + }, + ], +} + +describe("diffStatusPageConfig", () => { + it("reads omitted optional fields as their defaults, so a minimal copy changes nothing", () => { + const minimal: StatusPageConfig = { + name: "Acme", + slug: "acme", + components: [ + { id: API, name: "API" }, + { id: WEB, name: "Web" }, + { id: DOCS, name: "Docs" }, + ], + } + expect(diffStatusPageConfig(current, minimal)).toEqual({ + fields: [], + created: [], + updated: [], + deleted: [], + reordered: false, + unknownIds: [], + duplicateIds: [], + }) + }) + + it("lists changed fields, created, updated and deleted components and a new order", () => { + const changes = diffStatusPageConfig(current, { + ...current, + name: "Acme Inc", + theme: { preset: "paper" }, + components: [ + { id: DOCS, name: "Docs" }, + { id: API, name: "API", manual_status: "degraded" }, + { name: "Status" }, + ], + }) + expect(changes).toMatchObject({ + fields: ["name", "theme"], + created: ["Status"], + updated: ["API"], + deleted: ["Web"], + reordered: true, + }) + }) + + it("flags ids that are not on the page and ids listed twice", () => { + const changes = diffStatusPageConfig(current, { + ...current, + components: [ + { id: API, name: "API" }, + { id: API, name: "API again" }, + { id: STRAY, name: "Stray" }, + ], + }) + expect(changes.unknownIds).toEqual([STRAY]) + expect(changes.duplicateIds).toEqual([API]) + }) + + it("decodes public ids on the wire and refuses a component without a name", () => { + const decode = Schema.decodeUnknownSync(StatusPageConfig) + const encoded = Schema.encodeSync(StatusPageConfig)(current) + expect(encoded.components[0]?.id).toMatch(/^stc_/) + expect(decode(encoded)).toEqual(current) + expect(() => decode({ name: "Acme", slug: "acme", components: [{ name: "" }] })).toThrow() + }) +}) diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 42c8732336..6a20126c92 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -13,6 +13,7 @@ import { StatusPageUpdateSource, } from "../../primitives" import { + DEFAULT_STATUS_PAGE_THEME, StatusPageThemeAppearance, StatusPageThemeFont, StatusPageThemePreset, @@ -399,6 +400,193 @@ const StatusPageComponentList = ListOf(V2StatusPageComponent).annotate({ description: "A page's components in display order.", }) +// --- Config document --- + +const configExample = { + name: "Acme Status", + slug: "acme-status", + description: "Live status of Acme's API and dashboard.", + website_url: "https://acme.example", + logo_url: null, + show_powered_by: true, + auto_incidents: "draft", + theme: themeExample, + components: [ + { + id: "stc_YofPTrK9782DWwcnXhpcCw", + name: "API", + description: null, + group: null, + monitor_id: "upm_YofPTrK9782DWwcnXhpcCw", + manual_status: null, + show_uptime: true, + }, + { name: "Docs", group: "Websites" }, + ], +} as const + +export const StatusPageConfigTheme = Schema.Struct({ + preset: Schema.optionalKey(themeFields.preset), + appearance: Schema.optionalKey(themeFields.appearance), + accent: Schema.optionalKey(themeFields.accent), + background: Schema.optionalKey(themeFields.background), + text: Schema.optionalKey(themeFields.text), + card: Schema.optionalKey(themeFields.card), + border: Schema.optionalKey(themeFields.border), + radius: Schema.optionalKey(themeFields.radius), + font: Schema.optionalKey(themeFields.font), +}).annotate({ + identifier: "StatusPageConfigTheme", + title: "Status page config theme", + description: "The page theme. Omitted fields take the default theme's value.", + examples: [wireExample(themeExample)], +}) + +export const StatusPageConfigComponent = Schema.Struct({ + id: Schema.optionalKey( + StatusPageComponentPublicId.annotate({ + description: "An existing component of this page (`stc_…`) to update. Omit to add a new one.", + }), + ), + name: NonEmptyString.annotate({ examples: ["API"] }), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + group: Schema.optionalKey( + Schema.NullOr(Schema.String).annotate({ description: "Heading the component is listed under." }), + ), + monitor_id: Schema.optionalKey( + Schema.NullOr(UptimeMonitorPublicId).annotate({ + description: "The uptime monitor (`upm_…`) driving its status, or `null`.", + }), + ), + manual_status: Schema.optionalKey(Schema.NullOr(componentStatusField)), + show_uptime: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Whether the page shows its uptime bars. Default `true`." }), + ), +}).annotate({ + identifier: "StatusPageConfigComponent", + title: "Status page config component", + description: "One component; its place in the list is its position on the page.", +}) +export type StatusPageConfigComponent = Schema.Schema.Type + +export const StatusPageConfig = Schema.Struct({ + name: NonEmptyString.annotate({ description: "Page title.", 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)), + show_powered_by: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Whether the page credits Maple. Default `true`." }), + ), + auto_incidents: Schema.optionalKey(autoIncidentsField), + theme: Schema.optionalKey(StatusPageConfigTheme), + components: Schema.Array(StatusPageConfigComponent).annotate({ + description: + "Every component, in display order. Listed with an `id` updates it, without one adds it, and a component left out is deleted.", + }), +}).annotate({ + identifier: "StatusPageConfig", + title: "Status page config", + description: + "A whole status page as one document: its settings, theme and components, without incidents, the break-glass token or publish state. Applying it replaces the page; omitted optional fields take their defaults.", + examples: [wireExample(configExample)], +}) +export type StatusPageConfig = Schema.Schema.Type + +/** A config with every default filled in, the shape a read returns. */ +export const normalizeStatusPageConfig = (config: StatusPageConfig) => ({ + name: config.name, + slug: config.slug, + description: config.description ?? null, + website_url: config.website_url ?? null, + logo_url: config.logo_url ?? null, + show_powered_by: config.show_powered_by ?? true, + auto_incidents: config.auto_incidents ?? "off", + theme: { ...DEFAULT_STATUS_PAGE_THEME, ...config.theme }, + components: config.components.map((component) => ({ + ...(component.id === undefined ? undefined : { id: component.id }), + name: component.name, + description: component.description ?? null, + group: component.group ?? null, + monitor_id: component.monitor_id ?? null, + manual_status: component.manual_status ?? null, + show_uptime: component.show_uptime ?? true, + })), +}) + +const PAGE_CONFIG_FIELDS = [ + "name", + "slug", + "description", + "website_url", + "logo_url", + "show_powered_by", + "auto_incidents", + "theme", +] as const + +const COMPONENT_CONFIG_FIELDS = [ + "name", + "description", + "group", + "monitor_id", + "manual_status", + "show_uptime", +] as const + +export interface StatusPageConfigChanges { + readonly fields: ReadonlyArray<(typeof PAGE_CONFIG_FIELDS)[number]> + readonly created: ReadonlyArray + readonly updated: ReadonlyArray + readonly deleted: ReadonlyArray + readonly reordered: boolean + /** Ids listed that are not components of the page, or listed twice: the apply refuses both. */ + readonly unknownIds: ReadonlyArray + readonly duplicateIds: ReadonlyArray +} + +const sameValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right) + +/** What applying `next` over the page's current config would change; components by name. */ +export const diffStatusPageConfig = ( + current: StatusPageConfig, + next: StatusPageConfig, +): StatusPageConfigChanges => { + const before = normalizeStatusPageConfig(current) + const after = normalizeStatusPageConfig(next) + const existing = new Map( + before.components.flatMap((component) => + component.id === undefined ? [] : [[component.id, component] as const], + ), + ) + const listedIds = after.components.flatMap((component) => + component.id === undefined ? [] : [component.id], + ) + const listed = new Set(listedIds) + const keptOrder = listedIds.filter((id) => existing.has(id)) + return { + fields: PAGE_CONFIG_FIELDS.filter((field) => !sameValue(before[field], after[field])), + created: after.components.filter((component) => component.id === undefined).map((c) => c.name), + updated: after.components.flatMap((component) => { + const previous = component.id === undefined ? undefined : existing.get(component.id) + if (previous === undefined) return [] + const changed = COMPONENT_CONFIG_FIELDS.some( + (field) => !sameValue(previous[field], component[field]), + ) + return changed ? [component.name] : [] + }), + deleted: before.components + .filter((component) => component.id !== undefined && !listed.has(component.id)) + .map((component) => component.name), + reordered: !sameValue( + keptOrder, + [...existing.keys()].filter((id) => listed.has(id)), + ), + unknownIds: [...listed].filter((id) => !existing.has(id)), + duplicateIds: [...listed].filter((id) => listedIds.indexOf(id) !== listedIds.lastIndexOf(id)), + } +} + // --- Incidents --- export const V2StatusPageIncidentUpdate = Schema.Struct({ From 57d82c367777cfc69763eb40946d0616cb4bc972 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 08:59:46 +0100 Subject: [PATCH 02/17] feat(status-pages): GET and PUT /v2/status_pages/{id}/config The whole page as one document over API v2. PUT is admin-only, applies the document atomically and is audited as status_page.config_applied. --- .../src/routes/v2/status-pages.http.test.ts | 118 ++++++++++++++++++ apps/api/src/routes/v2/status-pages.http.ts | 37 +++++- docs/api-v2.md | 68 +++++----- packages/domain/src/audit-actions.ts | 2 + packages/domain/src/http/v2/openapi.test.ts | 2 + packages/domain/src/http/v2/status-pages.ts | 28 +++++ 6 files changed, 222 insertions(+), 33 deletions(-) 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 8c598bf804..88dde88415 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -465,4 +465,122 @@ describe("v2 status pages", () => { const intact = await harness.request("GET", incidentPath, token) expect(intact.body.status).toBe("investigating") })) + + describe("config", () => { + const seedPage = async (harness: ReturnType, token: string, slug: string) => { + const page = await harness.request("POST", "/v2/status_pages", token, { name: "Acme", slug }) + 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: "Web", group: "Apps" }) + return { id: page.body.id as string, apiId: api.body.id as string, webId: web.body.id as string } + } + + it("round-trips the document with a single revision bump", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id, apiId, webId } = await seedPage(harness, token, "acme-config") + const read = await harness.request("GET", `/v2/status_pages/${id}/config`, token) + expect(read.status).toBe(200) + expect(read.body).toMatchObject({ + name: "Acme", + slug: "acme-config", + show_powered_by: true, + auto_incidents: "off", + components: [ + { id: apiId, name: "API", group: null, monitor_id: null, show_uptime: true }, + { id: webId, name: "Web", group: "Apps" }, + ], + }) + expect(read.body).not.toHaveProperty("revision") + const before = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision + + const applied = await harness.request( + "PUT", + `/v2/status_pages/${id}/config`, + token, + read.body, + ) + expect(applied.status).toBe(200) + expect(applied.body).toEqual(read.body) + const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision + expect(after).toBe(before + 1) + })) + + it("applies created, updated, deleted and reordered components with the page fields", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id, apiId, webId } = await seedPage(harness, token, "acme-apply") + const applied = await harness.request("PUT", `/v2/status_pages/${id}/config`, token, { + name: "Acme Inc", + slug: "acme-apply", + description: "Live status.", + theme: { preset: "midnight" }, + components: [ + { name: "Docs" }, + { id: apiId, name: "Public API", manual_status: "degraded" }, + ], + }) + expect(applied.status).toBe(200) + expect(applied.body).toMatchObject({ + name: "Acme Inc", + description: "Live status.", + theme: { preset: "midnight", appearance: "auto", accent: null }, + components: [ + { name: "Docs", manual_status: null }, + { id: apiId, name: "Public API", manual_status: "degraded" }, + ], + }) + expect(applied.body.components[0].id).toMatch(/^stc_/) + const listed = await harness.request("GET", `/v2/status_pages/${id}/components`, token) + expect( + listed.body.data.map((c: { id: string; position: number }) => [c.id, c.position]), + ).toEqual([ + [applied.body.components[0].id, 0], + [apiId, 1], + ]) + expect(listed.body.data.some((c: { id: string }) => c.id === webId)).toBe(false) + })) + + it("refuses an invalid document with a 400 naming the problem and changes nothing", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-invalid") + const other = await seedPage(harness, token, "acme-invalid-other") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const foreign = await harness.request("PUT", path, token, { + ...read.body, + name: "Changed", + components: [...read.body.components, { id: other.apiId, name: "Stolen" }], + }) + expect(foreign.status).toBe(400) + expect(foreign.body.error.code).toBe("status_page_invalid") + expect(foreign.body.error.message).toContain(other.apiId) + const duplicate = await harness.request("PUT", path, token, { + ...read.body, + components: [...read.body.components, read.body.components[0]], + }) + expect(duplicate.status).toBe(400) + expect(duplicate.body.error.message).toContain("more than once") + const malformed = await harness.request("PUT", path, token, { name: "No slug" }) + expect(malformed.status).toBe(400) + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + + it("hides another org's config and refuses members", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { secret: outsider } = await harness.bootstrapKey(OTHER_ORG) + const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) + const { id } = await seedPage(harness, token, "acme-guarded") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + expect((await harness.request("GET", path, outsider)).status).toBe(404) + expect((await harness.request("PUT", path, outsider, read.body)).status).toBe(404) + expect((await harness.request("GET", path, member)).status).toBe(200) + const refused = await harness.request("PUT", path, member, { ...read.body, name: "Member" }) + expect(refused.status).toBe(403) + expect((await harness.request("GET", path, token)).body.name).toBe("Acme") + })) + }) }) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 330f4bede2..79894a1065 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -9,6 +9,7 @@ import { type StatusPageRecord, } from "@maple/domain/http" import { + diffStatusPageConfig, isoTimestamp, isoTimestampOrNull, MapleApiV2, @@ -29,7 +30,7 @@ import type { } from "@maple/domain/primitives" import { DEFAULT_STATUS_PAGE_THEME, isActiveIncident } from "@maple/domain/status-page" import { Effect, Option } from "effect" -import { auditDiff } from "@/routes/v2/audit-changes" +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" @@ -334,6 +335,40 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag return toV2StatusPage(yield* service.get(tenant.orgId, params.id)) }), ) + .handle("getConfig", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return yield* service.getConfig(tenant.orgId, params.id) + }), + ) + .handle("applyConfig", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("update")) + const before = yield* service.getConfig(tenant.orgId, params.id) + const applied = yield* service.applyConfig( + tenant.orgId, + params.id, + payload, + tenant.userId, + ) + const changes = diffStatusPageConfig(before, payload) + const { components: _before, ...pageBefore } = before + const { components: _after, ...pageAfter } = applied + yield* recordHttpAudit("status_page.config_applied", { + resourceId: params.id, + changes: diffAuditChanges(pageBefore, pageAfter), + metadata: { + name: applied.name, + components_created: changes.created.length, + components_updated: changes.updated.length, + components_deleted: changes.deleted.length, + components_reordered: changes.reordered, + }, + }) + return applied + }), + ) .handle("listComponents", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/docs/api-v2.md b/docs/api-v2.md index 8614fc9745..63675d4138 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -158,43 +158,47 @@ Stripe-style `expand[]` is deliberately omitted: responses embed the small, alwa Implemented in phases; the pilot (`api_keys`) ships first and proves every convention. -| Resource | Endpoints | Backing service | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | -| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | -| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | -| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | -| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | -| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | -| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | -| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | -| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | -| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | -| `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` | -| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | -| `integrations/gcp` ✅ | status (connectors + `metrics_available`) + admin-only connector create/update/delete (project, folder or organization scope) and `setup_scripts` | `GcpConnectorService` | -| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | -| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | -| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | -| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | -| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | -| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | -| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | -| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | -| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | +| Resource | Endpoints | Backing service | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | +| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | +| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | +| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | +| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | +| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | +| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | +| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | +| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | +| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | +| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | +| `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`) + `config` (`GET`, atomic replace `PUT`) | `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` | +| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | +| `integrations/gcp` ✅ | status (connectors + `metrics_available`) + admin-only connector create/update/delete (project, folder or organization scope) and `setup_scripts` | `GcpConnectorService` | +| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | +| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | +| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | +| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | +| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | +| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | +| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | +| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | +| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | Also mounted, outside this table: `audit_log` (list), `environments`, `mcp_connections` (list, revoke; members see and revoke their own, admins everyone's), `instrumentation/signals` (retrieve), `widget_credentials` (mint/revoke), `widget_summary` (retrieve), and `share` (share-link reads). The contracts are in `packages/domain/src/http/v2/`. The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare infra, the PlanetScale infra timeseries) lives in the internal tier at `/internal/query-engine`, session-only and undocumented, and is promoted into `/v2` individually as shapes stabilize. +### Status page config document + +`GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document. + ### Telemetry reads Phase 2 exposes traces, logs, metrics, services, and the service map. Telemetry search and aggregation requests require explicit ISO-8601 UTC `start_time` and `end_time`. Search windows are capped at 7 days, timeseries at 31 days and 1,500 buckets, and breakdowns at 30 days. A breakdown over 24 hours requires an additional narrowing filter. Direct trace/span retrieval uses the `(OrgId, TraceId, SpanId)` sorting-key prefix so it returns the complete retained trace without a correctness-limiting timestamp window. Direct log retrieval derives its narrow partition window from the timestamp embedded in the log ID. diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index b12d880405..f6606609b5 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -128,6 +128,7 @@ export const AuditResources = { "deleted", "published", "components_reordered", + "config_applied", "break_glass_rotated", "break_glass_revoked", ], @@ -322,6 +323,7 @@ export const AuditActionLabels = { "status_page.deleted": "Deleted status page", "status_page.published": "Published status page", "status_page.components_reordered": "Reordered status page components", + "status_page.config_applied": "Applied status page config", "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", diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 0bcc6762e4..c032d23467 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -179,6 +179,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/status_pages", "GET /v2/status_pages/{id}", "GET /v2/status_pages/{id}/components", + "GET /v2/status_pages/{id}/config", "GET /v2/support/slack_channel", "GET /v2/traces/{trace_id}", "GET /v2/traces/{trace_id}/spans/{span_id}", @@ -285,6 +286,7 @@ describe("MapleApiV2 OpenAPI", () => { "PUT /v2/error_issues/{id}/severity", "PUT /v2/mobile_devices/{token}", "PUT /v2/mobile_devices/{token}/live_activities/{incident_id}", + "PUT /v2/status_pages/{id}/config", "PUT /v2/widget_credentials/{installation_id}", ]) }) diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 6a20126c92..4a5b9d33ad 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -892,6 +892,34 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") }), ), ) + .add( + HttpApiEndpoint.get("getConfig", "/:id/config", { + params: pageParams, + success: StatusPageConfig, + error: [pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getStatusPageConfig", + summary: "Retrieve a status page config", + description: + "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes. Requires the `status_pages:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.put("applyConfig", "/:id/config", { + params: pageParams, + payload: StatusPageConfig, + success: StatusPageConfig, + error: [V2InsufficientPermissions.schema, pageNotFound, validation, slugTaken, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "applyStatusPageConfig", + summary: "Apply a status page config", + description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part rejects the whole document. Returns the applied config. ${adminNote}`, + }), + ), + ) .add( HttpApiEndpoint.get("listComponents", "/:id/components", { params: pageParams, From f3b53841f45dbbe2baa7d632d8d3ef5f099771a6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 09:02:48 +0100 Subject: [PATCH 03/17] feat(status-pages): get_status_page_config and apply_status_page_config MCP tools The page document as fenced JSON, and an admin-only atomic apply that takes it as an object or JSON text and summarises what changed. --- apps/ai/src/mcp/resources/instructions.ts | 2 +- .../tools/__tests__/status-page-tools.test.ts | 102 +++++++++++++++ apps/ai/src/mcp/tools/mutating.ts | 1 + apps/ai/src/mcp/tools/status-pages.ts | 120 +++++++++++++++++- packages/domain/src/mcp-outputs/catalog.ts | 10 +- .../domain/src/mcp-outputs/status-pages.ts | 26 ++++ 6 files changed, 257 insertions(+), 4 deletions(-) diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 417486d4d4..3555fdb29a 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -116,7 +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. +- 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. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write. - 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 index e219db30e4..0709129f24 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 @@ -1,6 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { Schema } from "effect" +import { RoleName } from "@maple/domain/http" import { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput, @@ -224,4 +227,103 @@ describe("status page tools", () => { ) expect((await freshness()).state).toBe("live") }) + + describe("config", () => { + const PAGE = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e01" + const admin = () => ({ + ...rt, + tenant: { ...rt.tenant, roles: [Schema.decodeUnknownSync(RoleName)("org:admin")] }, + }) + beforeAll(async () => { + 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, 'Config Page', 'config-page', $3, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`, + [PAGE, 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, 'Web', 0, now(), now()), ($4, $2, $3, 'Docs', 1, now(), now())`, + [ + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e02", + rt.tenant.orgId, + PAGE, + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e03", + ], + ) + }) + + it("reads the page as a JSON document with public ids, fenced for the model", async () => { + const result = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = result.structuredContent + expect(config).toMatchObject({ + name: "Config Page", + slug: "config-page", + components: [{ name: "Web" }, { name: "Docs" }], + }) + expect(config.components[0].id).toMatch(/^stc_/) + expect(decode(GetStatusPageConfigOutput, result).config.components).toHaveLength(2) + expect(markdown(result)).toContain("```json\n") + expect(markdown(result)).toContain(`"id": "${config.components[0].id}"`) + }) + + it("applies an edited document passed as a JSON string and summarises what changed", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const [web] = read.structuredContent.config.components + const edited = { + ...read.structuredContent.config, + description: "Live status.", + components: [{ name: "API" }, { ...web, group: "Apps" }], + } + const result = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: JSON.stringify(edited), + }) + expect(result.isError).not.toBe(true) + const output = decode(ApplyStatusPageConfigOutput, result) + expect(output.changes).toEqual({ + fields: ["description"], + created: ["API"], + updated: ["Web"], + deleted: ["Docs"], + reordered: false, + }) + expect(output.config.components.map((c) => c.name)).toEqual(["API", "Web"]) + const text = markdown(result) + expect(text).toContain("Components created") + expect(text).toContain("Docs") + + const again = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: result.structuredContent.config, + }) + expect(markdown(again)).toContain("Nothing changed") + }) + + it("refuses members and names a component id from another page", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const refused = await runToolDirect(rt, "apply_status_page_config", { + page: "config-page", + config: read.structuredContent.config, + }) + expect(refused.isError).toBe(true) + expect(markdown(refused)).toContain("only org admins") + + const other = await runToolDirect(rt, "get_status_page_config", { page: "acme-status" }) + const stolen = other.structuredContent.config.components[0].id + const foreign = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { + ...read.structuredContent.config, + components: [{ id: stolen, name: "Stolen" }], + }, + }) + expect(foreign.isError).toBe(true) + expect(markdown(foreign)).toContain(stolen) + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(read.structuredContent.config) + }) + }) }) diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index 91735d8320..492713619c 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -48,6 +48,7 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "post_status_page_update", "resolve_status_page_incident", "schedule_status_page_maintenance", + "apply_status_page_config", // feedback "send_maple_feedback", ]) diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index 6a802daacf..e0afdfa462 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -5,13 +5,21 @@ */ import { Clock, Effect, Schema } from "effect" import { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput, type StatusPageIncidentRow, type StatusPageRow, } from "@maple/domain/mcp-outputs" -import { decodePublicId, encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { + decodePublicId, + diffStatusPageConfig, + encodePublicId, + PublicIdPrefixes, + StatusPageConfig, +} from "@maple/domain/http/v2" import type { StatusPageComponentRecord, StatusPageIncidentRecord, @@ -26,15 +34,17 @@ import { } from "@maple/domain/primitives" import { isActiveIncident, overallStatus, pageFreshness } from "@maple/domain/status-page" import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine" +import { requireAdmin } from "@maple/backend/services/auth/auth" 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 { McpInvalidInputError, McpQueryError, McpUnavailableError, 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 encodeConfig = Schema.encodeSync(StatusPageConfig) const iso = (ms: number) => new Date(ms).toISOString() const isoOrNull = (ms: number | null) => (ms === null ? null : iso(ms)) @@ -61,6 +71,7 @@ const toMcpError = parameter: "components", }) case "@maple/http/errors/StatusPageValidationError": + case "@maple/http/errors/StatusPageSlugTakenError": return new McpInvalidInputError({ message: error.message }) default: return new McpQueryError({ message: `${error._tag}: ${error.message}`, pipeName: tool }) @@ -574,4 +585,109 @@ export function registerStatusPageTools(server: McpToolRegistrar) { }), render: (output) => renderIncident("Maintenance scheduled", output), }) + + server.define({ + name: "get_status_page_config", + title: "Get Status Page Config", + description: + "One status page's settings, theme and ordered components as a JSON document (no incidents, break-glass token or publish state). Edit it and pass it to apply_status_page_config to change the page in one write. Same document as GET /v2/status_pages/{id}/config.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id (list_status_pages lists them)"), + }), + output: GetStatusPageConfigOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Reading a status page config"], + handler: Effect.fn("McpTool.getStatusPageConfig")(function* (params) { + const tool = "get_status_page_config" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const config = yield* service + .getConfig(tenant.orgId, page.id) + .pipe(Effect.mapError(toMcpError(tool))) + return { page: toPageRow(page), config } + }), + render: (output) => ({ + title: `Status page config: ${output.page.name}`, + notices: [ + "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order.", + ], + blocks: [doc.code("json", JSON.stringify(encodeConfig(output.config), null, 2))], + }), + }) + + server.define({ + name: "apply_status_page_config", + title: "Apply Status Page Config", + description: + "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults. Only org admins may call it, and the result is public on the page.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id"), + config: P.json( + StatusPageConfig, + "The whole page document, as returned by get_status_page_config, edited", + ), + }), + output: ApplyStatusPageConfigOutput, + hints: { readOnly: false, destructive: true, idempotent: false, openWorld: true }, + phrases: ["Applying a status page config"], + handler: Effect.fn("McpTool.applyStatusPageConfig")(function* (params) { + const tool = "apply_status_page_config" + const tenant = yield* CurrentMcpTenant + yield* requireAdmin( + tenant.roles, + () => + new McpUnavailableError({ + message: `${tool} changes a public status page, which only org admins may do. Ask an admin, or edit it in Maple under Status pages.`, + capability: "org_admin", + }), + ) + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const before = yield* service + .getConfig(tenant.orgId, page.id) + .pipe(Effect.mapError(toMcpError(tool))) + const config = yield* service + .applyConfig(tenant.orgId, page.id, params.config, tenant.userId) + .pipe(Effect.mapError(toMcpError(tool))) + const { fields, created, updated, deleted, reordered } = diffStatusPageConfig( + before, + params.config, + ) + const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool))) + return { + page: toPageRow(applied), + config, + changes: { fields, created, updated, deleted, reordered }, + } + }), + render: (output) => { + const { fields, created, updated, deleted, reordered } = output.changes + const unchanged = + fields.length === 0 && + created.length === 0 && + updated.length === 0 && + deleted.length === 0 && + !reordered + const list = (names: ReadonlyArray) => (names.length === 0 ? undefined : names.join(", ")) + return { + 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."], + blocks: [ + doc.fields([ + ["Fields changed", list(fields)], + ["Components created", list(created)], + ["Components updated", list(updated)], + ["Components deleted", list(deleted)], + ["Order changed", reordered ? "yes" : undefined], + ]), + ], + next: [ + doc.next("get_status_page", { page: output.page.slug }, "the page as visitors see it"), + ], + } + }, + }) } diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index e790b2e815..e8d546e752 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -6,7 +6,13 @@ 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 { + ApplyStatusPageConfigOutput, + GetStatusPageConfigOutput, + GetStatusPageOutput, + ListStatusPagesOutput, + StatusPageIncidentMutationOutput, +} from "./status-pages" import { DeleteIngestRuleOutput, IngestRuleMutationOutput, @@ -177,6 +183,8 @@ export const McpToolOutputs = { list_status_pages: ListStatusPagesOutput, list_uptime_monitors: ListUptimeMonitorsOutput, get_status_page: GetStatusPageOutput, + get_status_page_config: GetStatusPageConfigOutput, + apply_status_page_config: ApplyStatusPageConfigOutput, post_status_page_update: StatusPageIncidentMutationOutput, resolve_status_page_incident: StatusPageIncidentMutationOutput, schedule_status_page_maintenance: StatusPageIncidentMutationOutput, diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts index 2ebaa05ba8..5bd3839738 100644 --- a/packages/domain/src/mcp-outputs/status-pages.ts +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { StatusPageConfig } from "../http/v2/status-pages" import { StatusPageAutoIncidents, StatusPageComponentStatus, @@ -90,3 +91,28 @@ export const StatusPageIncidentMutationOutput = Schema.Struct({ action: Schema.Literals(["created", "updated", "resolved", "scheduled"]), incident: StatusPageIncidentRow, }) + +// --------------------------------------------------------------------------------------------- +// get_status_page_config / apply_status_page_config + +export const GetStatusPageConfigOutput = Schema.Struct({ + page: StatusPageRow, + /** The document `PUT /v2/status_pages/{id}/config` and apply_status_page_config take. */ + config: StatusPageConfig, +}) + +const StatusPageConfigChangesRow = Schema.Struct({ + /** Page fields whose value changed. */ + fields: Schema.Array(Schema.String), + /** Component names, as listed in the applied config. */ + created: Schema.Array(Schema.String), + updated: Schema.Array(Schema.String), + deleted: Schema.Array(Schema.String), + reordered: Schema.Boolean, +}) + +export const ApplyStatusPageConfigOutput = Schema.Struct({ + page: StatusPageRow, + config: StatusPageConfig, + changes: StatusPageConfigChangesRow, +}) From 0dfbe3a082f0062f0adc9d6c9178baabcb211664 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 09:11:00 +0100 Subject: [PATCH 04/17] feat(web): status page JSON tab with a validated, atomic apply The page as its config document to copy. Admins edit it in place: the draft is parsed and decoded as they type, with the line and column of a syntax error or the path of a schema error, a summary of what will change, and Apply blocked while the Settings tab has unsaved edits. --- .../alerts/shared/status-page-commands.ts | 10 +- .../alerts/status-pages/editor/command.ts | 12 + .../alerts/status-pages/editor/config-json.ts | 196 +++++++++++++++ .../status-pages/editor/editor.scene.test.ts | 188 +++++++++++++- .../alerts/status-pages/editor/message.ts | 9 + .../page/alerts/status-pages/editor/model.ts | 6 + .../alerts/status-pages/editor/update-json.ts | 105 ++++++++ .../page/alerts/status-pages/editor/update.ts | 15 +- .../alerts/status-pages/editor/view-json.ts | 237 ++++++++++++++++++ .../page/alerts/status-pages/editor/view.ts | 10 + 10 files changed, 785 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/page/alerts/status-pages/editor/config-json.ts create mode 100644 apps/web/src/page/alerts/status-pages/editor/update-json.ts create mode 100644 apps/web/src/page/alerts/status-pages/editor/view-json.ts diff --git a/apps/web/src/page/alerts/shared/status-page-commands.ts b/apps/web/src/page/alerts/shared/status-page-commands.ts index fcb0c184d7..cedc754d66 100644 --- a/apps/web/src/page/alerts/shared/status-page-commands.ts +++ b/apps/web/src/page/alerts/shared/status-page-commands.ts @@ -1,5 +1,6 @@ import { type StatusPageComponentId, StatusPageId, type StatusPageIncidentId } from "@maple/domain" import { + type StatusPageConfig, StatusPagePublicId, type V2StatusPageComponentCreateParams, type V2StatusPageCreateParams, @@ -149,6 +150,13 @@ export const reorderComponents = (id: StatusPageId, componentIds: ReadonlyArray< return response.data }).pipe(Effect.mapError(toLoadFailure)) +/** Replaces the page with `config` in one write and returns the applied document. */ +export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.applyConfig({ params: { id }, payload: config }) + }).pipe(Effect.mapError(toLoadFailure)) + export const createIncident = (payload: V2StatusPageIncidentCreateParams) => Effect.gen(function* () { const client = yield* MapleApiV2Client @@ -177,7 +185,7 @@ const decodeInternalId = Schema.decodeUnknownOption(StatusPageId) export const pageIdFromUrl = (raw: string): Option.Option => Option.orElse(decodePublicId(raw), () => decodeInternalId(raw)) -export type EditorTab = "components" | "incidents" | "settings" +export type EditorTab = "components" | "incidents" | "settings" | "json" export const statusPageHref = (id: StatusPageId, tab?: EditorTab): string => hrefOf( diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/alerts/status-pages/editor/command.ts index 6a0366b742..cada0e5e16 100644 --- a/apps/web/src/page/alerts/status-pages/editor/command.ts +++ b/apps/web/src/page/alerts/status-pages/editor/command.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" import { + StatusPageConfig, V2StatusPageComponentCreateParams, V2StatusPageIncidentCreateParams, V2StatusPageIncidentPostUpdateParams, @@ -9,6 +10,7 @@ import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" import { readIsAdmin } from "../../shared/commands" import { + applyPageConfig, createIncident, deleteComponent, deletePage, @@ -183,6 +185,16 @@ export const RevokeToken = Command.define("RevokeStatusPageBreakGlassToken", { ), }) +export const ApplyConfig = Command.define("ApplyStatusPageConfig", { + args: { pageId: StatusPageId, config: StatusPageConfig }, + messages: [Message.CompletedApplyJson], + execute: ({ pageId, config }) => + applyPageConfig(pageId, config).pipe( + Effect.result, + Effect.map((result) => Message.CompletedApplyJson({ result })), + ), +}) + export const DeletePage = Command.define("DeleteStatusPage", { args: { pageId: StatusPageId }, messages: [Message.CompletedDeletePage], diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.ts new file mode 100644 index 0000000000..a851b653cc --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.ts @@ -0,0 +1,196 @@ +import { + diffStatusPageConfig, + StatusPageComponentPublicId, + StatusPageConfig, + type StatusPageConfigChanges, + type V2StatusPage, + type V2StatusPageComponent, +} from "@maple/domain/http/v2" +import { Result, Schema, SchemaIssue } from "effect" + +// The JSON tab's document: the loaded page as a `StatusPageConfig`, and a draft parsed back. + +/** The same document `GET /v2/status_pages/{id}/config` returns, from the reads already loaded. */ +export const configOf = ( + page: V2StatusPage, + components: ReadonlyArray, +): StatusPageConfig => ({ + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.website_url, + logo_url: page.logo_url, + show_powered_by: page.show_powered_by, + auto_incidents: page.auto_incidents, + theme: page.theme, + components: components.map((component) => ({ + id: component.id, + name: component.name, + description: component.description, + group: component.group, + monitor_id: component.monitor_id, + manual_status: component.manual_status, + show_uptime: component.show_uptime, + })), +}) + +const encodeConfig = Schema.encodeSync(StatusPageConfig) +const encodeId = Schema.encodeSync(StatusPageComponentPublicId) + +/** Pretty-printed with public ids, two-space indent. */ +export const formatConfig = (config: StatusPageConfig): string => + JSON.stringify(encodeConfig(config), null, 2) + +export type DraftProblem = + | { readonly _tag: "Syntax"; readonly message: string; readonly line: number; readonly column: number } + | { readonly _tag: "Schema"; readonly path: string; readonly message: string } + +export type DraftResult = Result.Result< + { readonly config: StatusPageConfig; readonly changes: StatusPageConfigChanges }, + DraftProblem +> + +// JSON SYNTAX +// Engines word (and locate) parse errors differently, so a small scanner finds the first one. + +const WHITESPACE = /[ \t\n\r]*/y +const STRING = /"(?:[^"\\\n\r\t]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/y +const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y +const LITERAL = /true|false|null/y + +type Scan = + | { readonly ok: true; readonly at: number } + | { readonly ok: false; readonly at: number; readonly message: string } + +const skip = (text: string, at: number): number => { + WHITESPACE.lastIndex = at + WHITESPACE.test(text) + return WHITESPACE.lastIndex +} + +const token = (pattern: RegExp, text: string, at: number): number | null => { + pattern.lastIndex = at + return pattern.test(text) ? pattern.lastIndex : null +} + +const fail = (at: number, message: string): Scan => ({ ok: false, at, message }) + +const scanString = (text: string, at: number): Scan => { + const end = token(STRING, text, at) + return end === null ? fail(at, "Unterminated or invalid string") : { ok: true, at: end } +} + +const scanMembers = ( + text: string, + at: number, + close: "}" | "]", + member: (text: string, at: number) => Scan, +): Scan => { + const scanned = member(text, skip(text, at)) + if (!scanned.ok) return scanned + const next = skip(text, scanned.at) + if (text[next] === ",") return scanMembers(text, next + 1, close, member) + if (text[next] === close) return { ok: true, at: next + 1 } + return fail(next, `Expected ',' or '${close}'`) +} + +const scanProperty = (text: string, at: number): Scan => { + if (text[at] !== '"') return fail(at, "Expected a double-quoted property name") + const key = scanString(text, at) + if (!key.ok) return key + const colon = skip(text, key.at) + if (text[colon] !== ":") return fail(colon, "Expected ':' after the property name") + return scanValue(text, skip(text, colon + 1)) +} + +const scanValue = (text: string, at: number): Scan => { + const char = text[at] + if (char === "{") { + const inner = skip(text, at + 1) + return text[inner] === "}" ? { ok: true, at: inner + 1 } : scanMembers(text, inner, "}", scanProperty) + } + if (char === "[") { + const inner = skip(text, at + 1) + return text[inner] === "]" + ? { ok: true, at: inner + 1 } + : scanMembers(text, inner, "]", (source, from) => scanValue(source, from)) + } + if (char === '"') return scanString(text, at) + const end = token(NUMBER, text, at) ?? token(LITERAL, text, at) + if (end !== null) return { ok: true, at: end } + return fail(at, at >= text.length ? "Unexpected end of the document" : "Expected a value") +} + +const locate = (text: string, at: number) => { + const before = text.slice(0, at).split("\n") + return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 } +} + +const syntaxProblem = (text: string): DraftProblem => { + const scanned = scanValue(text, skip(text, 0)) + const end = scanned.ok ? skip(text, scanned.at) : scanned.at + const message = scanned.ok ? "Unexpected text after the document" : scanned.message + return { _tag: "Syntax", message, ...locate(text, Math.min(end, text.length)) } +} + +// SCHEMA + +const formatIssue = SchemaIssue.makeFormatterStandardSchemaV1() + +type PathSegment = PropertyKey | { readonly key: PropertyKey } + +const pathOf = (path: ReadonlyArray): string => + path + .map((segment) => (typeof segment === "object" ? segment.key : segment)) + .reduce( + (out, key) => + typeof key === "number" + ? `${out}[${key}]` + : out === "" + ? String(key) + : `${out}.${String(key)}`, + "", + ) + +const parseJson = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)) +const decode = Schema.decodeUnknownResult(StatusPageConfig) + +/** A draft decoded against the schema, unknown keys refused, then the ids the apply would refuse. */ +export const parseDraft = (text: string, current: StatusPageConfig): DraftResult => + Result.mapError(parseJson(text), () => syntaxProblem(text)).pipe( + Result.flatMap((value) => + Result.mapError(decode(value, { onExcessProperty: "error" }), (error): DraftProblem => { + const [first] = formatIssue(error.issue).issues + return { + _tag: "Schema", + path: pathOf(first?.path ?? []), + message: first?.message ?? error.message, + } + }), + ), + Result.flatMap((config): DraftResult => { + const changes = diffStatusPageConfig(current, config) + const [unknown] = changes.unknownIds + if (unknown !== undefined) + return Result.fail({ + _tag: "Schema", + path: `components[${config.components.findIndex((c) => c.id === unknown)}].id`, + message: `${encodeId(unknown)} is not a component of this page. Remove the id to add a new one.`, + }) + const [duplicate] = changes.duplicateIds + if (duplicate !== undefined) + return Result.fail({ + _tag: "Schema", + path: `components[${config.components.map((c) => c.id).lastIndexOf(duplicate)}].id`, + message: `${encodeId(duplicate)} is listed more than once`, + }) + return Result.succeed({ config, changes }) + }), + ) + +export const problemText = (problem: DraftProblem): string => + problem._tag === "Syntax" + ? `Line ${problem.line}, column ${problem.column}: ${problem.message}` + : problem.path === "" + ? problem.message + : `${problem.path}: ${problem.message}` diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 2367a8b701..767ec1cf9e 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { StatusPagePublicId, type V2StatusPage } from "@maple/domain/http/v2" +import { StatusPageComponentPublicId, StatusPagePublicId, type V2StatusPage } from "@maple/domain/http/v2" import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" import { Option, Result, Schema } from "effect" import { @@ -538,3 +538,189 @@ describe("settings tab", () => { ) }) }) + +describe("json tab", () => { + const publicComponentId = (n: number) => Schema.encodeSync(StatusPageComponentPublicId)(componentId(n)) + /** The document the tab shows, as the user would edit it. */ + const documentOf = (components: ReadonlyArray) => ({ + name: "Acme Status", + slug: "acme-status", + components: components.map((n) => ({ id: publicComponentId(n), name: `Component ${n}` })), + }) + const editing = (draft: string, fixture: Fixture = {}) => + [Message.ClickedEditJson(), Message.ChangedJson({ value: draft })].reduce( + (model, message) => pageUpdate(model, message).model, + loaded({ tab: "json", ...fixture }), + ) + + test("shows the page as its config document, with public ids", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json" })), + expectView(role("link", { name: "JSON" })).toHaveAttr("aria-current", "page"), + expectView(selector("pre")).toContainText('"slug": "acme-status"'), + expectView(selector("pre")).toContainText(`"id": "${publicComponentId(2)}"`), + expectView(selector("pre")).toContainText('"auto_incidents": "draft"'), + expectView(role("button", { name: "Copy config JSON" })).toExist(), + expectView(text("GET /v2/status_pages/{id}/config")).toExist(), + ) + }) + + test("members read the document but cannot edit it", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json", isAdmin: false })), + expectView(selector("pre")).toContainText('"name": "Acme Status"'), + expectView(role("button", { name: "Edit JSON" })).not.toExist(), + expectView(role("textbox", { name: "Config JSON" })).not.toExist(), + ) + const tried = pageUpdate(loaded({ tab: "json", isAdmin: false }), Message.ClickedEditJson()) + expect(tried.model.jsonDraft).toBeNull() + }) + + test("Edit fills the editor with the current document, and Reset brings it back", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "json" })), + click(role("button", { name: "Edit JSON" })), + expectView(role("textbox", { name: "Config JSON" })).toExist(), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + type(role("textbox", { name: "Config JSON" }), "{}"), + expectView(text("Not a valid page config")).toExist(), + click(role("button", { name: "Reset" })), + expectView(text("No changes yet.")).toExist(), + ) + }) + + test("invalid JSON shows where it breaks and disables Apply", () => { + scene( + { update: pageUpdate, view }, + given(editing('{\n "name": "Acme",\n "slug" "acme"\n}')), + expectView(text("Invalid JSON")).toExist(), + expectView(text("Line 3, column 10: Expected ':' after the property name")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("a schema error names the path of the bad field", () => { + const draft = JSON.stringify({ ...documentOf([1]), components: [{ name: "" }] }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView(text("Not a valid page config")).toExist(), + expectView(text(/^components\[0\]\.name: /u)).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("an id from elsewhere is refused before the write", () => { + const draft = JSON.stringify({ + ...documentOf([1]), + components: [{ id: publicComponentId(9), name: "X" }], + }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView( + text( + `components[0].id: ${publicComponentId(9)} is not a component of this page. Remove the id to add a new one.`, + ), + ).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("the summary lists a created and a deleted component", () => { + const draft = JSON.stringify({ + ...documentOf([1, 2]), + components: [...documentOf([1, 2]).components, { name: "Status API" }], + }) + scene( + { update: pageUpdate, view }, + given(editing(draft)), + expectView(text("Components to create")).toExist(), + expectView(text("Status API")).toExist(), + expectView(text("Components to delete")).toExist(), + expectView(text("Component 3")).toExist(), + expectView(text("Fields changed")).toExist(), + expectView(role("button", { name: "Apply" })).toBeEnabled(), + ) + }) + + test("Apply sends one PUT with the decoded config, then re-reads the page and components", () => { + const draft = JSON.stringify({ ...documentOf([2, 1]), components: documentOf([2, 1]).components }) + const model = editing(draft) + const applying = pageUpdate(model, Message.ClickedApplyJson()) + expect(applying.commands).toMatchObject([ + { + name: "ApplyStatusPageConfig", + args: { + pageId: statusPage().id, + config: { + name: "Acme Status", + slug: "acme-status", + components: [ + { id: componentId(2), name: "Component 2" }, + { id: componentId(1), name: "Component 1" }, + ], + }, + }, + }, + ]) + expect(applying.commands).toHaveLength(1) + expect(pageUpdate(applying.model, Message.ClickedApplyJson()).commands ?? []).toEqual([]) + + const applied = pageUpdate( + applying.model, + Message.CompletedApplyJson({ + result: Result.succeed({ name: "Acme Status", slug: "acme-status", components: [] }), + }), + ) + expect(applied.model.jsonDraft).toBeNull() + expect(names(applied.commands)).toContain("FetchStatusPage") + expect(names(applied.commands)).toContain("FetchStatusPageComponents") + }) + + test("a refused apply shows the server's reason inline and keeps the draft", () => { + const reason = "Text needs a contrast of 4.5:1" + const model = editing(JSON.stringify(documentOf([1]))) + const applying = pageUpdate(model, Message.ClickedApplyJson()).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error(reason)), + message: reason, + retryable: false, + }), + }), + ).model + expect(refused.jsonDraft).toBe(model.jsonDraft) + scene( + { update: pageUpdate, view }, + given(refused), + expectView(text("Couldn't apply the config")).toExist(), + expectView(text(reason)).toExist(), + ) + }) + + test("unsaved Settings edits block Apply until they are saved or discarded", () => { + const draft = JSON.stringify(documentOf([1])) + const dirty = pageUpdate( + editing(draft), + Message.ChangedSetting({ field: "name", value: "Edited elsewhere" }), + ).model + scene( + { update: pageUpdate, view }, + given(dirty), + expectView(text("Unsaved settings")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + expect(pageUpdate(dirty, Message.ClickedApplyJson()).commands ?? []).toEqual([]) + const discarded = pageUpdate(dirty, Message.ClickedDiscardSettings()).model + expect(names(pageUpdate(discarded, Message.ClickedApplyJson()).commands)).toContain( + "ApplyStatusPageConfig", + ) + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/message.ts b/apps/web/src/page/alerts/status-pages/editor/message.ts index f350e6db35..bc5dd61f86 100644 --- a/apps/web/src/page/alerts/status-pages/editor/message.ts +++ b/apps/web/src/page/alerts/status-pages/editor/message.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageIncidentId, StatusPageIncidentKind } from "@maple/domain" import { + StatusPageConfig, V2StatusPage, V2StatusPageBreakGlassToken, V2StatusPageComponent, @@ -89,5 +90,13 @@ export const Message = defineMessageUnion({ ClickedConfirmDeletePage: {}, CompletedDeletePage: { result: outcome(Schema.Void) }, GotDeletePageDialogMessage: { message: Dialog.Message }, + // JSON + ClickedEditJson: {}, + ChangedJson: { value: Schema.String }, + ClickedResetJson: {}, + ClickedCancelJson: {}, + ClickedApplyJson: {}, + CompletedApplyJson: { result: outcome(StatusPageConfig) }, + GotJsonCopyMessage: { message: CopyButton.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts index c15f0589e1..b2fbe9f0af 100644 --- a/apps/web/src/page/alerts/status-pages/editor/model.ts +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -72,5 +72,11 @@ export const Model = Schema.Struct({ tokenCopy: CopyButton.Model, deletePageDialog: Dialog.Model, isDeletingPage: Schema.Boolean, + // JSON + /** The edited document; `null` while the tab shows the page as saved. */ + jsonDraft: Schema.NullOr(Schema.String), + isApplyingJson: Schema.Boolean, + jsonFailure: Schema.NullOr(LoadFailure), + jsonCopy: CopyButton.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts new file mode 100644 index 0000000000..2a1bf3a48e --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -0,0 +1,105 @@ +import type { StatusPageConfig } from "@maple/domain/http/v2" +import { Option, Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import type { LoadFailure } from "../../../../data/load-failure" +import * as CopyButton from "../../../../ui/copy-button" +import type { PageContext } from "../../../contract" +import { ApplyConfig } from "./command" +import { configOf, type DraftResult, formatConfig, parseDraft } from "./config-json" +import { Message } from "./message" +import type { Model } from "./model" +import { chain, componentsOf, pageOf, readComponents, readPage, type Return, toast } from "./reads" +import { isSettingsDirty } from "./update-settings" + +// The JSON tab: the page as one document, and an admin's edit of it applied in one write. + +/** The saved page as its config, once the page and its components are in. */ +export const currentConfigOf = (model: Model): Option.Option => + AsyncData.hasData(model.components.data) + ? Option.map(pageOf(model), (page) => configOf(page, componentsOf(model))) + : Option.none() + +/** The draft parsed against the saved page; none while not editing. */ +export const draftOf = (model: Model): Option.Option => + model.jsonDraft === null + ? Option.none() + : Option.map(currentConfigOf(model), (current) => parseDraft(model.jsonDraft ?? "", current)) + +const hasChanges = (draft: DraftResult): boolean => + Result.match(draft, { + onFailure: () => false, + onSuccess: ({ changes }) => + changes.fields.length > 0 || + changes.created.length > 0 || + changes.updated.length > 0 || + changes.deleted.length > 0 || + changes.reordered, + }) + +/** Apply needs a valid draft that changes something, and no unsaved Settings edits to fight. */ +export const canApply = (model: Model): boolean => + model.isAdmin && + !model.isApplyingJson && + !isSettingsDirty(model) && + Option.match(draftOf(model), { onNone: () => false, onSome: hasChanges }) + +const withCurrent = (model: Model, write: (text: string) => Model): Return => + Option.match(currentConfigOf(model), { + onNone: () => ({ model }), + onSome: (current) => ({ model: write(formatConfig(current)) }), + }) + +/** Opens the editor on the saved document; Reset runs it again over the draft. */ +export const editJson = (model: Model): Return => + model.isAdmin + ? withCurrent(model, (jsonDraft) => ({ ...model, jsonDraft, jsonFailure: null })) + : { model } + +export const changeJson = (model: Model, value: string): Return => + model.jsonDraft === null ? { model } : { model: { ...model, jsonDraft: value, jsonFailure: null } } + +export const cancelJson = (model: Model): Return => + model.isApplyingJson ? { model } : { model: { ...model, jsonDraft: null, jsonFailure: null } } + +export const applyJson = (model: Model): Return => { + const pageId = model.pageId + if (pageId === null || !canApply(model)) return { model } + return Option.match(draftOf(model), { + onNone: () => ({ model }), + onSome: (draft) => + Result.match(draft, { + onFailure: () => ({ model }), + onSuccess: ({ config }) => ({ + model: { ...model, isApplyingJson: true, jsonFailure: null }, + commands: [ApplyConfig({ pageId, config })], + }), + }), + }) +} + +export const appliedJson = ( + model: Model, + result: Result.Result, + context: PageContext, +): Return => + Result.match(result, { + onSuccess: (): Return => ({ + ...chain( + { ...model, isApplyingJson: false, jsonDraft: null, settingsEdits: null }, + readPage(context, "Supersede"), + readComponents(context, "Supersede"), + ), + outMessage: toast("success", "Config applied", "It goes out with the next publish."), + }), + onFailure: (jsonFailure): Return => ({ model: { ...model, isApplyingJson: false, jsonFailure } }), + }) + +export const updateJsonCopy = (model: Model, message: CopyButton.Message): Return => { + const step = CopyButton.update(model.jsonCopy, message) + return { + model: { ...model, jsonCopy: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (child) => + Message.GotJsonCopyMessage({ message: child }), + ), + } +} diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 6da1a98568..46e69d73dc 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -56,12 +56,13 @@ import { changeTheme, updateTokenCopy, } from "./update-settings" +import { appliedJson, applyJson, cancelJson, changeJson, editJson, updateJsonCopy } from "./update-json" type EditorRoute = Extract /** Unknown `tab` values open Components, so an old or mistyped link still lands. */ export const resolveTab = (tab: string | undefined): EditorTab => - tab === "incidents" || tab === "settings" ? tab : "components" + tab === "incidents" || tab === "settings" || tab === "json" ? tab : "components" export const init = (route: EditorRoute, context: PageContext): Return => { const model: Model = { @@ -104,6 +105,10 @@ export const init = (route: EditorRoute, context: PageContext): Return => { tokenCopy: CopyButton.init(), deletePageDialog: Dialog.init("status-page-delete", "alert-dialog"), isDeletingPage: false, + jsonDraft: null, + isApplyingJson: false, + jsonFailure: null, + jsonCopy: CopyButton.init(), } const reads = chain( model, @@ -240,4 +245,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re whenIdle(model.isDeletingPage, model, () => deletePageDialog(model, Dialog.update(model.deletePageDialog, child)), ), + // JSON + ClickedEditJson: () => editJson(model), + ChangedJson: ({ value }) => changeJson(model, value), + ClickedResetJson: () => editJson(model), + ClickedCancelJson: () => cancelJson(model), + ClickedApplyJson: () => applyJson(model), + CompletedApplyJson: ({ result }) => appliedJson(model, result, context), + GotJsonCopyMessage: ({ message: child }) => updateJsonCopy(model, child), }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view-json.ts b/apps/web/src/page/alerts/status-pages/editor/view-json.ts new file mode 100644 index 0000000000..1276adfc56 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/view-json.ts @@ -0,0 +1,237 @@ +import type { StatusPageConfigChanges } from "@maple/domain/http/v2" +import { Option, Result } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "../../../../data/load-failure" +import { alert, alertDescription, alertTitle } from "../../../../ui/alert" +import { button } from "../../../../ui/button" +import * as CopyButton from "../../../../ui/copy-button" +import { icon } from "../../../../ui/icon" +import { label } from "../../../../ui/label" +import { panel, panelBody, panelHeader } from "../../../../ui/panel" +import { textarea } from "../../../../ui/textarea" +import { adminOnlyNote } from "../../../settings/kit/section" +import { formatConfig, problemText } from "./config-json" +import { Message } from "./message" +import type { Model } from "./model" +import { canApply, currentConfigOf, draftOf } from "./update-json" +import { isSettingsDirty } from "./update-settings" + +// The JSON tab: the page as one document to copy, and for admins an editor that applies it. + +type H = HtmlBuilder + +const code = (h: H, text: string): Html => h.code([h.Class("font-mono text-xs")], [text]) + +const intro = (h: H): Html => + h.p( + [h.Class("font-prose text-sm text-muted-foreground")], + [ + "The page's settings, theme and components as one document, without incidents. Same document as ", + code(h, "GET /v2/status_pages/{id}/config"), + " and the MCP tool ", + code(h, "get_status_page_config"), + ". Applying it replaces the page: a component without an ", + code(h, "id"), + " is added, one left out is deleted, and list order is display order.", + ], + ) + +const changeRows = (changes: StatusPageConfigChanges): ReadonlyArray => [ + ...(changes.fields.length === 0 ? [] : [["Fields changed", changes.fields.join(", ")] as const]), + ...(changes.created.length === 0 ? [] : [["Components to create", changes.created.join(", ")] as const]), + ...(changes.updated.length === 0 ? [] : [["Components to update", changes.updated.join(", ")] as const]), + ...(changes.deleted.length === 0 ? [] : [["Components to delete", changes.deleted.join(", ")] as const]), + ...(changes.reordered ? [["Order", "Components change order"] as const] : []), +] + +const summary = (h: H, changes: StatusPageConfigChanges): Html => { + const rows = changeRows(changes) + return panel(h, {}, [ + panelHeader(h, { title: "Changes" }), + panelBody(h, [ + rows.length === 0 + ? h.p([h.Class("text-sm text-muted-foreground")], ["No changes yet."]) + : h.dl( + [h.Class("grid grid-cols-[max-content_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-sm")], + rows.flatMap(([term, detail]) => [ + h.dt([h.Class("text-muted-foreground")], [term]), + h.dd([h.Class("text-foreground")], [detail]), + ]), + ), + ]), + ]) +} + +const readView = (h: H, model: Model, text: string): Html => + panel(h, {}, [ + panelHeader(h, { + title: "status-page.json", + action: h.div( + [h.Class("flex items-center gap-2")], + [ + CopyButton.view(h, { + model: model.jsonCopy, + toParentMessage: (message) => Message.GotJsonCopyMessage({ message }), + value: text, + label: "config JSON", + idleLabel: "Copy", + variant: "outline", + size: "sm", + }), + ...(model.isAdmin + ? [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedEditJson())], + }, + [icon(h, "PencilIcon"), "Edit JSON"], + ), + ] + : []), + ], + ), + }), + h.pre( + [ + h.AriaLabel("Status page config"), + h.Class("max-h-[70vh] overflow-auto p-4 font-mono text-xs leading-relaxed text-foreground"), + ], + [text], + ), + ]) + +const editView = (h: H, model: Model, draft: string): Html => { + const parsed = draftOf(model) + const problem = Option.flatMap(parsed, (result) => Result.getFailure(result)) + const changes = Option.flatMap(parsed, (result) => + Option.map(Result.getSuccess(result), (value) => value.changes), + ) + const isBlocked = isSettingsDirty(model) + return h.div( + [h.Class("flex flex-col gap-4")], + [ + ...(isBlocked + ? [ + alert(h, { variant: "warn", size: "sm" }, [ + icon(h, "CircleWarningIcon"), + alertTitle(h, ["Unsaved settings"]), + alertDescription(h, [ + "The Settings tab has unsaved edits. Save or discard them before applying JSON.", + ]), + ]), + ] + : []), + h.div( + [h.Class("flex flex-col gap-1.5")], + [ + label( + h, + { + className: "text-xs text-muted-foreground", + attributes: [h.For("status-page-json")], + }, + ["Config JSON"], + ), + textarea(h, { + id: "status-page-json", + controlClassName: "min-h-[28rem] font-mono text-xs leading-relaxed", + attributes: [ + h.Value(draft), + h.Rows(28), + h.Spellcheck(false), + h.Attribute("autocapitalize", "off"), + h.OnInput((value) => Message.ChangedJson({ value })), + ...(Option.isSome(problem) + ? [h.AriaInvalid(true), h.AriaDescribedBy("status-page-json-error")] + : []), + ], + }), + ], + ), + Option.match(problem, { + onSome: (value) => + h.div( + [h.Id("status-page-json-error")], + [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + value._tag === "Syntax" ? "Invalid JSON" : "Not a valid page config", + ]), + alertDescription(h, [ + h.span([h.Class("font-mono text-xs")], [problemText(value)]), + ]), + ]), + ], + ), + onNone: () => + Option.match(changes, { onSome: (value) => summary(h, value), onNone: () => h.empty }), + }), + ...(model.jsonFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't apply the config"]), + alertDescription(h, [failureMessage(model.jsonFailure)]), + ]), + ]), + h.div( + [h.Class("flex items-center justify-end gap-2")], + [ + button( + h, + { + size: "sm", + variant: "ghost", + isDisabled: model.isApplyingJson, + attributes: [h.OnClick(Message.ClickedResetJson())], + }, + ["Reset"], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: model.isApplyingJson, + attributes: [h.OnClick(Message.ClickedCancelJson())], + }, + ["Cancel"], + ), + button( + h, + { + size: "sm", + isLoading: model.isApplyingJson, + isDisabled: !canApply(model), + attributes: [h.OnClick(Message.ClickedApplyJson())], + }, + ["Apply"], + ), + ], + ), + ], + ) +} + +export const jsonTab = (h: H, model: Model): Html => + Option.match(currentConfigOf(model), { + onNone: () => h.empty, + onSome: (current) => + h.div( + [h.Class("flex max-w-4xl flex-col gap-4")], + [ + intro(h), + ...(model.isAdmin + ? [] + : [adminOnlyNote(h, ["Only org admins can apply a config. You can copy it."])]), + model.isAdmin && model.jsonDraft !== null + ? editView(h, model, model.jsonDraft) + : readView(h, model, formatConfig(current)), + ], + ), + }) diff --git a/apps/web/src/page/alerts/status-pages/editor/view.ts b/apps/web/src/page/alerts/status-pages/editor/view.ts index 4fe92c0256..c2d691c1d5 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view.ts @@ -35,6 +35,7 @@ import { componentsOf, incidentsOf, monitorsOf, pageOf } from "./reads" import { resolveTab } from "./update" import { componentDialog, componentsTab, deleteComponentDialog } from "./view-components" import { incidentDialog, incidentsTab, updateDialog } from "./view-incidents" +import { jsonTab } from "./view-json" import { breakGlassDialog, deletePageDialog, settingsTab } from "./view-settings" // `/alerts/status-pages/$pageId`: the header, the tabs, and the dialogs every tab opens. @@ -45,6 +46,7 @@ const TABS: ReadonlyArray<{ value: EditorTab; label: string }> = [ { value: "components", label: "Components" }, { value: "incidents", label: "Incidents" }, { value: "settings", label: "Settings" }, + { value: "json", label: "JSON" }, ] const tabBar = (h: H, page: V2StatusPage, active: EditorTab, openIncidents: number): Html => @@ -129,6 +131,12 @@ const tabRead = (model: Model, tab: EditorTab): { failure: Option.Option Date: Thu, 29 Oct 2026 09:13:04 +0100 Subject: [PATCH 05/17] fix(web): pad the JSON tab's change summary --- .../alerts/status-pages/editor/view-json.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/view-json.ts b/apps/web/src/page/alerts/status-pages/editor/view-json.ts index 1276adfc56..5179988e1a 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view-json.ts @@ -48,17 +48,21 @@ const summary = (h: H, changes: StatusPageConfigChanges): Html => { const rows = changeRows(changes) return panel(h, {}, [ panelHeader(h, { title: "Changes" }), - panelBody(h, [ - rows.length === 0 - ? h.p([h.Class("text-sm text-muted-foreground")], ["No changes yet."]) - : h.dl( - [h.Class("grid grid-cols-[max-content_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-sm")], - rows.flatMap(([term, detail]) => [ - h.dt([h.Class("text-muted-foreground")], [term]), - h.dd([h.Class("text-foreground")], [detail]), - ]), - ), - ]), + panelBody( + h, + [ + rows.length === 0 + ? h.p([h.Class("text-sm text-muted-foreground")], ["No changes yet."]) + : h.dl( + [h.Class("grid grid-cols-[max-content_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-sm")], + rows.flatMap(([term, detail]) => [ + h.dt([h.Class("text-muted-foreground")], [term]), + h.dd([h.Class("text-foreground")], [detail]), + ]), + ), + ], + "p-4", + ), ]) } From 2767b51d1e7a1ad3ec7f6bcd21e892068f0aa520 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 09:34:10 +0100 Subject: [PATCH 06/17] fix(status): config apply locks the page, takes if_revision, refuses unknown keys and reports its own changes applyConfig locks the page row before reading components (component writes take the same lock), refuses a stale if_revision with a 409 StatusPageConflictError, caps a page at 200 components (createComponent too), and returns the change set computed from the rows it read and the normalized values it wrote. normalizeStatusPageConfig now trims, nulls blank text and lower-cases hex, so padding or case is no change. GET /config carries the revision. PUT /config decodes its payload with onExcessProperty "error"; MCP tools can opt into the same with rejectUnknownKeys. apply_status_page_config sends the document's revision, maps the conflict to a typed error telling the model to re-read, renders the service's changes, and writes a status_page.config_applied audit entry. --- .../tools/__tests__/status-page-tools.test.ts | 66 ++++++++ apps/ai/src/mcp/tools/registry.ts | 16 +- apps/ai/src/mcp/tools/status-pages.ts | 60 ++++--- apps/ai/src/mcp/tools/types.ts | 5 + .../src/routes/v2/status-pages.http.test.ts | 69 +++++++- apps/api/src/routes/v2/status-pages.http.ts | 16 +- docs/api-v2.md | 2 +- .../status-pages/StatusPagesService.test.ts | 85 +++++++++- .../status-pages/StatusPagesService.ts | 96 ++++++++++- .../anticipated-error-identifiers.ts | 1 + packages/domain/src/http/status-pages.ts | 19 +++ .../src/http/v2/status-page-config.test.ts | 29 ++++ packages/domain/src/http/v2/status-pages.ts | 153 +++++++++++++----- 13 files changed, 524 insertions(+), 93 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 0709129f24..8efb928811 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 @@ -302,6 +302,72 @@ describe("status page tools", () => { expect(markdown(again)).toContain("Nothing changed") }) + it("reports nothing changed for a re-sent document that differs only until stored", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + const result = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { + ...config, + name: ` ${config.name} `, + description: config.description === null ? " " : ` ${config.description} `, + components: config.components.map((c: { name: string }) => ({ + ...c, + name: ` ${c.name} `, + })), + }, + }) + expect(result.isError).not.toBe(true) + expect(decode(ApplyStatusPageConfigOutput, result).changes).toEqual({ + fields: [], + created: [], + updated: [], + deleted: [], + reordered: false, + }) + expect(markdown(result)).toContain("Nothing changed") + }) + + it("refuses a key the config does not define, as an object or as JSON text", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + const [first, ...rest] = config.components + const { show_uptime: _shown, ...withoutUptime } = first + const typo = { ...config, components: [{ ...withoutUptime, show_uptim: false }, ...rest] } + const asObject = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: typo, + }) + expect(asObject.isError).toBe(true) + expect(markdown(asObject)).toContain("show_uptim") + const asText = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: JSON.stringify({ ...config, colour: "red" }), + }) + expect(asText.isError).toBe(true) + expect(markdown(asText)).toContain("colour") + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(config) + }) + + it("refuses a document read before the page changed, and says to read it again", async () => { + const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const { config } = read.structuredContent + await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { ...config, description: "Changed meanwhile." }, + }) + const moved = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + const stale = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: { ...config, components: [] }, + }) + expect(stale.isError).toBe(true) + expect(markdown(stale)).toContain("get_status_page_config again") + const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) + expect(after.structuredContent.config).toEqual(moved.structuredContent.config) + }) + it("refuses members and names a component id from another page", async () => { const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) const refused = await runToolDirect(rt, "apply_status_page_config", { diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 2e0331ada1..959a45eab7 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -1,7 +1,7 @@ // BOUNDARY: This module owns unparsed external values and narrows them before domain use. import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import type { McpToolSurface } from "@maple/domain/mcp-manifest" -import { Effect, Schema } from "effect" +import { Effect, Schema, type SchemaAST } from "effect" import { registerAddDashboardWidgetTool } from "./add-dashboard-widget" import { registerDescribeWarehouseTablesTool } from "./describe-warehouse-tables" import { registerComparePeriodsTool } from "./compare-periods" @@ -122,6 +122,8 @@ export interface MapleToolCatalogEntry { readonly title: string readonly description: string readonly schema: Schema.Codec + /** How arguments decode; `rejectUnknownKeys` tools refuse nested keys the schema lacks. */ + readonly parseOptions: SchemaAST.ParseOptions readonly outputSchema: Schema.Codec readonly hints: McpToolHints readonly aliases: Readonly> @@ -343,6 +345,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { title: spec.title, description: spec.description, schema: spec.parameters, + parseOptions: { onExcessProperty: spec.rejectUnknownKeys === true ? "error" : "ignore" }, outputSchema: spec.output, hints: spec.hints, aliases: spec.aliases ?? {}, @@ -511,7 +514,9 @@ export const readToolCall = ( return { args: isArgumentRecord(normalized.args) ? normalized.args : {}, unknown: normalized.unknown.map((entry) => entry.key), - decodes: Schema.decodeUnknownOption(definition.schema)(normalized.args)._tag === "Some", + decodes: + Schema.decodeUnknownOption(definition.schema)(normalized.args, definition.parseOptions)._tag === + "Some", } } @@ -550,7 +555,7 @@ const isValidNextCall = (call: NextCall, surface: McpToolSurface): boolean => { const normalized = normalizeFor(target, call.args) return ( normalized.unknown.length === 0 && - Schema.decodeUnknownOption(target.schema)(normalized.args)._tag === "Some" + Schema.decodeUnknownOption(target.schema)(normalized.args, target.parseOptions)._tag === "Some" ) } @@ -619,7 +624,10 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu ? undefined : { "maple.mcp.tool.aliased_arguments": normalized.renamed.map(([from]) => from).join(",") }), }) - const decoded = yield* Schema.decodeUnknownEffect(definition.schema)(normalized.args).pipe( + const decoded = yield* Schema.decodeUnknownEffect(definition.schema)( + normalized.args, + definition.parseOptions, + ).pipe( Effect.mapError( (error) => new McpDecodeError({ diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index e0afdfa462..a3c5ba42f5 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -13,13 +13,7 @@ import { type StatusPageIncidentRow, type StatusPageRow, } from "@maple/domain/mcp-outputs" -import { - decodePublicId, - diffStatusPageConfig, - encodePublicId, - PublicIdPrefixes, - StatusPageConfig, -} from "@maple/domain/http/v2" +import { decodePublicId, encodePublicId, PublicIdPrefixes, StatusPageConfig } from "@maple/domain/http/v2" import type { StatusPageComponentRecord, StatusPageIncidentRecord, @@ -34,6 +28,9 @@ import { } 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 { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import { requireAdmin } from "@maple/backend/services/auth/auth" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" @@ -73,6 +70,11 @@ const toMcpError = case "@maple/http/errors/StatusPageValidationError": case "@maple/http/errors/StatusPageSlugTakenError": return new McpInvalidInputError({ message: error.message }) + case "@maple/http/errors/StatusPageConflictError": + return new McpInvalidInputError({ + message: `${error.message} Nothing was applied. Call get_status_page_config again and redo the edit on the fresh document; applying the old one would undo the other change.`, + parameter: "if_revision", + }) default: return new McpQueryError({ message: `${error._tag}: ${error.message}`, pipeName: tool }) } @@ -610,7 +612,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { render: (output) => ({ title: `Status page config: ${output.page.name}`, notices: [ - "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order.", + "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order. Keep the `revision` key: the apply is refused if the page changed since this read.", ], blocks: [doc.code("json", JSON.stringify(encodeConfig(output.config), null, 2))], }), @@ -620,14 +622,18 @@ export function registerStatusPageTools(server: McpToolRegistrar) { name: "apply_status_page_config", title: "Apply Status Page Config", description: - "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults. Only org admins may call it, and the result is public on the page.", + "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults; unknown keys are refused. The document's `revision` guards the write: if the page changed since it was read, nothing is applied and you must read it again. Only org admins may call it, and the result is public on the page.", parameters: Schema.Struct({ page: P.text("The page's slug, name or id"), config: P.json( StatusPageConfig, - "The whole page document, as returned by get_status_page_config, edited", + "The whole page document, as returned by get_status_page_config, edited. Keys it does not define are refused", + ), + if_revision: P.optionalNumber( + "The page revision the document was read at. Defaults to the document's `revision`; the apply is refused if the page changed since", ), }), + rejectUnknownKeys: true, output: ApplyStatusPageConfigOutput, hints: { readOnly: false, destructive: true, idempotent: false, openWorld: true }, phrases: ["Applying a status page config"], @@ -644,22 +650,28 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ) const service = yield* StatusPagesService const page = yield* resolvePage(tool, tenant.orgId, params.page) - const before = yield* service - .getConfig(tenant.orgId, page.id) - .pipe(Effect.mapError(toMcpError(tool))) - const config = yield* service - .applyConfig(tenant.orgId, page.id, params.config, tenant.userId) + const { config, changes } = yield* service + .applyConfig(tenant.orgId, page.id, params.config, tenant.userId, { + ifRevision: params.if_revision ?? params.config.revision, + }) .pipe(Effect.mapError(toMcpError(tool))) - const { fields, created, updated, deleted, reordered } = diffStatusPageConfig( - before, - params.config, - ) + const audit = yield* AuditLogService + yield* audit.record({ + orgId: tenant.orgId, + ...auditAttribution(tenant, yield* CurrentAuditActor), + action: "status_page.config_applied", + resourceId: page.id, + metadata: { + name: config.name, + fields_changed: changes.fields.join(","), + components_created: changes.created.length, + components_updated: changes.updated.length, + components_deleted: changes.deleted.length, + components_reordered: changes.reordered, + }, + }) const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool))) - return { - page: toPageRow(applied), - config, - changes: { fields, created, updated, deleted, reordered }, - } + return { page: toPageRow(applied), config, changes } }), render: (output) => { const { fields, created, updated, deleted, reordered } = output.changes diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index b2edbed4a7..a921159542 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -184,6 +184,11 @@ export interface McpToolSpec< readonly phrases: McpToolPhrases /** Retired parameter names mapped to their current name. Accepted, never published. */ readonly aliases?: Readonly> + /** + * Refuse keys a nested object parameter does not declare, instead of dropping them. For + * documents that replace state, where a dropped typo would reset a field to its default. + */ + readonly rejectUnknownKeys?: true readonly handler: (params: P["Type"]) => Effect.Effect /** The model-facing text, derived from the output alone so the two cannot disagree. */ readonly render: (output: O["Type"]) => ToolDoc 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 88dde88415..993e55a787 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -491,8 +491,8 @@ describe("v2 status pages", () => { { id: webId, name: "Web", group: "Apps" }, ], }) - expect(read.body).not.toHaveProperty("revision") const before = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision + expect(read.body.revision).toBe(before) const applied = await harness.request( "PUT", @@ -501,7 +501,7 @@ describe("v2 status pages", () => { read.body, ) expect(applied.status).toBe(200) - expect(applied.body).toEqual(read.body) + expect(applied.body).toEqual({ ...read.body, revision: before + 1 }) const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision expect(after).toBe(before + 1) })) @@ -567,6 +567,71 @@ describe("v2 status pages", () => { expect((await harness.request("GET", path, token)).body).toEqual(read.body) })) + it("refuses a key the document does not define, naming it, and changes nothing", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-typo") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const [first, ...rest] = read.body.components + const { show_uptime: _shown, ...withoutUptime } = first + const typo = await harness.request("PUT", path, token, { + ...read.body, + name: "Changed", + components: [{ ...withoutUptime, show_uptim: false }, ...rest], + }) + expect(typo.status).toBe(400) + expect(typo.body.error.param).toBe("components[0].show_uptim") + const topLevel = await harness.request("PUT", path, token, { ...read.body, colour: "red" }) + expect(topLevel.status).toBe(400) + expect(topLevel.body.error.param).toBe("colour") + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + + it("with a stale if_revision answers 409 and writes nothing; a current one applies", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-stale") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + await harness.request("POST", `/v2/status_pages/${id}/components`, token, { name: "Added" }) + const moved = await harness.request("GET", path, token) + const stale = await harness.request( + "PUT", + `${path}?if_revision=${read.body.revision}`, + token, + { ...read.body, name: "Stale" }, + ) + expect(stale.status).toBe(409) + expect(stale.body.error.code).toBe("status_page_conflict") + expect(stale.body.error.message).toContain(`revision ${read.body.revision}`) + expect((await harness.request("GET", path, token)).body).toEqual(moved.body) + + const current = await harness.request( + "PUT", + `${path}?if_revision=${moved.body.revision}`, + token, + { ...moved.body, name: "Fresh" }, + ) + expect(current.status).toBe(200) + expect(current.body.name).toBe("Fresh") + expect(current.body.components.map((c: { name: string }) => c.name)).toContain("Added") + })) + + it("refuses more than 200 components", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { id } = await seedPage(harness, token, "acme-big") + const path = `/v2/status_pages/${id}/config` + const read = await harness.request("GET", path, token) + const tooMany = await harness.request("PUT", path, token, { + ...read.body, + components: Array.from({ length: 201 }, (_, index) => ({ name: `C${index}` })), + }) + expect(tooMany.status).toBe(400) + expect((await harness.request("GET", path, token)).body).toEqual(read.body) + })) + it("hides another org's config and refuses members", () => withHarness(async (harness) => { const { secret: token } = await harness.bootstrapKey() diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 79894a1065..3b45f8d4ce 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -9,7 +9,6 @@ import { type StatusPageRecord, } from "@maple/domain/http" import { - diffStatusPageConfig, isoTimestamp, isoTimestampOrNull, MapleApiV2, @@ -341,32 +340,31 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag return yield* service.getConfig(tenant.orgId, params.id) }), ) - .handle("applyConfig", ({ params, payload }) => + .handle("applyConfig", ({ params, query, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("update")) - const before = yield* service.getConfig(tenant.orgId, params.id) - const applied = yield* service.applyConfig( + const { previous, config, changes } = yield* service.applyConfig( tenant.orgId, params.id, payload, tenant.userId, + { ifRevision: query.if_revision }, ) - const changes = diffStatusPageConfig(before, payload) - const { components: _before, ...pageBefore } = before - const { components: _after, ...pageAfter } = applied + const { components: _before, revision: _beforeRevision, ...pageBefore } = previous + const { components: _after, revision: _afterRevision, ...pageAfter } = config yield* recordHttpAudit("status_page.config_applied", { resourceId: params.id, changes: diffAuditChanges(pageBefore, pageAfter), metadata: { - name: applied.name, + name: config.name, components_created: changes.created.length, components_updated: changes.updated.length, components_deleted: changes.deleted.length, components_reordered: changes.reordered, }, }) - return applied + return config }), ) .handle("listComponents", ({ params, query }) => diff --git a/docs/api-v2.md b/docs/api-v2.md index 63675d4138..ff6572559b 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -197,7 +197,7 @@ The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/work ### Status page config document -`GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document. +`GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400, as does a key the document does not define (so a typo never resets a field) or more than 200 components. The read carries the page `revision`; the apply ignores that key, but `PUT …/config?if_revision=` answers 409 `status_page_conflict` and writes nothing when the page has moved past `n`. The apply locks the page row before reading its components, and component writes take the same lock, so they serialize. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document, and both send the revision they read. ### Telemetry reads diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 873f0f716d..2ef5a794ce 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -542,6 +542,7 @@ describe("StatusPagesService", () => { }) describe("config", () => { + const NO_CHANGES = { fields: [], created: [], updated: [], deleted: [], reordered: false } const cleanupSlugs = (testDb: TestDb) => Effect.promise(() => testDb.pglite.query<{ slug: string }>( @@ -566,6 +567,7 @@ describe("StatusPagesService", () => { return Effect.gen(function* () { const { page, api, web, docs, config } = yield* seeded(testDb) assert.deepStrictEqual(config, { + revision: yield* revision(testDb, page.id), name: page.name, slug: "config", description: null, @@ -595,7 +597,7 @@ describe("StatusPagesService", () => { const { service, page, api, web, docs } = yield* seeded(testDb) yield* service.update(ORG, page.id, { description: "Old", showPoweredBy: false }) const before = yield* revision(testDb, page.id) - const applied = yield* service.applyConfig( + const { config: applied, changes } = yield* service.applyConfig( ORG, page.id, { @@ -640,6 +642,13 @@ describe("StatusPagesService", () => { listed.map((row) => row.id), ) assert.deepStrictEqual(applied, yield* service.getConfig(ORG, page.id)) + assert.deepStrictEqual(changes, { + fields: ["name", "description", "show_powered_by", "theme"], + created: ["Status API"], + updated: ["Docs site"], + deleted: ["Web"], + reordered: true, + }) }).pipe(Effect.provide(makeLayer(testDb))) }, ) @@ -649,7 +658,9 @@ describe("StatusPagesService", () => { return Effect.gen(function* () { const { service, page, config } = yield* seeded(testDb) const before = yield* revision(testDb, page.id) - assert.deepStrictEqual(yield* service.applyConfig(ORG, page.id, config, USER), config) + const applied = yield* service.applyConfig(ORG, page.id, config, USER) + assert.deepStrictEqual(applied.config, { ...config, revision: before + 1 }) + assert.deepStrictEqual(applied.changes, NO_CHANGES) assert.strictEqual(yield* revision(testDb, page.id), before + 1) }).pipe(Effect.provide(makeLayer(testDb))) }) @@ -734,11 +745,79 @@ describe("StatusPagesService", () => { { ...config, slug: "config-new" }, USER, ) - assert.strictEqual(renamed.slug, "config-new") + assert.strictEqual(renamed.config.slug, "config-new") assert.deepStrictEqual(yield* cleanupSlugs(testDb), ["config"]) }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("reports no change for values that differ only until stored", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + yield* service.update(ORG, page.id, { + theme: { ...DEFAULT_STATUS_PAGE_THEME, appearance: "light", accent: "#1a2b3c" }, + }) + const current = yield* service.getConfig(ORG, page.id) + const [first, ...rest] = current.components + assert.isDefined(first) + const padded: StatusPageConfig = { + ...current, + name: ` ${current.name} `, + description: " ", + theme: { ...current.theme, accent: "#1A2B3C" }, + components: [{ ...first, name: ` ${first.name} `, group: " " }, ...rest], + } + const applied = yield* service.applyConfig(ORG, page.id, padded, USER) + assert.deepStrictEqual(applied.changes, NO_CHANGES) + assert.deepStrictEqual(applied.config.components, config.components) + assert.strictEqual(applied.config.theme?.accent, "#1a2b3c") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("with if_revision, a page that moved on is a conflict and nothing is written", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config } = yield* seeded(testDb) + const stale = config.revision ?? 0 + yield* service.createComponent(ORG, page.id, { name: "Added meanwhile" }) + const moved = yield* service.getConfig(ORG, page.id) + const conflict = yield* Effect.flip( + service.applyConfig(ORG, page.id, config, USER, { ifRevision: stale }), + ) + assert.strictEqual(conflict._tag, "@maple/http/errors/StatusPageConflictError") + assert.include(conflict.message, `revision ${stale}`) + assert.deepStrictEqual(yield* service.getConfig(ORG, page.id), moved) + + const applied = yield* service.applyConfig(ORG, page.id, moved, USER, { + ifRevision: moved.revision, + }) + assert.deepStrictEqual(applied.changes, NO_CHANGES) + assert.strictEqual(applied.config.revision, (moved.revision ?? 0) + 1) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("caps a page at 200 components, for an apply and for a single create", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page } = yield* seeded(testDb) + const document = (count: number): StatusPageConfig => ({ + name: "Big", + slug: "config", + components: Array.from({ length: count }, (_, index) => ({ name: `C${index}` })), + }) + const tooMany = yield* Effect.flip(service.applyConfig(ORG, page.id, document(201), USER)) + assert.strictEqual(tooMany._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(tooMany.message, "at most 200 components") + assert.lengthOf(yield* service.listComponents(ORG, page.id), 3) + + yield* service.applyConfig(ORG, page.id, document(200), USER) + const extra = yield* Effect.flip(service.createComponent(ORG, page.id, { name: "201st" })) + assert.strictEqual(extra._tag, "@maple/http/errors/StatusPageValidationError") + assert.include(extra.message, "at most 200 components") + assert.lengthOf(yield* service.listComponents(ORG, page.id), 200) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + it.effect("a missing or cross-org page is not found", () => { 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 6b2a210cc8..5b5cb40fb3 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -15,6 +15,7 @@ import { } from "@maple/db/tables" import { StatusPageComponentNotFoundError, + StatusPageConflictError, StatusPageIncidentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, @@ -26,9 +27,12 @@ import { type StatusPageRecord, } from "@maple/domain/http" import { + diffStatusPageConfig, + MAX_STATUS_PAGE_COMPONENTS, normalizeStatusPageConfig, StatusPageComponentPublicId, type StatusPageConfig, + type StatusPageConfigChangeSet, UptimeMonitorPublicId, } from "@maple/domain/http/v2" import { @@ -228,13 +232,27 @@ export interface StatusPagesServiceApi { ) => Effect.Effect /** The page's settings, theme and components as one document. */ readonly getConfig: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect - /** Replaces the page with `config` in one transaction: all of it applies, or none of it. */ + /** + * Replaces the page with `config` in one transaction: all of it applies, or none of it. With + * `ifRevision`, a page past that revision fails with a conflict and nothing is written. + */ readonly applyConfig: ( orgId: OrgId, pageId: StatusPageId, config: StatusPageConfig, actor: UserId, - ) => Effect.Effect + options?: { readonly ifRevision?: number | undefined }, + ) => Effect.Effect< + AppliedStatusPageConfig, + PageWriteError | StatusPageSlugTakenError | StatusPageConflictError + > +} + +/** The page before and after the write, and what changed, all read under the page lock. */ +export interface AppliedStatusPageConfig { + readonly previous: StatusPageConfig + readonly config: StatusPageConfig + readonly changes: StatusPageConfigChangeSet } const MAX_NAME_LENGTH = 120 @@ -466,6 +484,28 @@ const findPage = ( ) } +/** + * The page row, locked until the transaction ends. Every component write takes it first, so an + * apply and component edits on one page run one at a time. + */ +const lockPage = (db: MapleDb, orgId: OrgId, pageId: StatusPageId) => + db + .run( + PG.from(StatusPages) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(pageId)]) + .limit(1) + .forUpdate(), + ) + .pipe( + Effect.flatMap((rows) => + rows[0] === undefined ? Effect.fail(pageNotFound(pageId)) : Effect.succeed(rows[0]), + ), + ) + +const tooManyComponents = () => + invalid(`A status page can have at most ${MAX_STATUS_PAGE_COMPONENTS} components`) + const findComponent = (db: MapleDb, orgId: OrgId, componentId: StatusPageComponentId) => db .run( @@ -588,6 +628,7 @@ const toConfig = ( page: StatusPageRow, components: ReadonlyArray, ): StatusPageConfig => ({ + revision: page.revision, name: page.name, slug: page.slug, description: page.description, @@ -898,9 +939,11 @@ export class StatusPagesService extends Context.Service db.transaction( Effect.gen(function* () { - yield* findPage(db, orgId, pageId) + yield* lockPage(db, orgId, pageId) if (monitorId !== null) yield* ensureMonitorInOrg(db, orgId, monitorId) const existing = yield* selectComponents(db, orgId, pageId) + if (existing.length >= MAX_STATUS_PAGE_COMPONENTS) + return yield* tooManyComponents() const inserted = yield* db.run( PG.insertInto(StatusPageComponents) .values({ @@ -957,6 +1000,7 @@ export class StatusPagesService extends Context.Service [ $.orgId.eq(orgId), @@ -1019,7 +1064,7 @@ export class StatusPagesService extends Context.Service db.transaction( Effect.gen(function* () { - yield* findPage(db, orgId, pageId) + yield* lockPage(db, orgId, pageId) const existing = yield* selectComponents(db, orgId, pageId) const requested = new Set(componentIds) const sameSet = @@ -1337,6 +1382,7 @@ export class StatusPagesService extends Context.Service MAX_STATUS_PAGE_COMPONENTS) return yield* tooManyComponents() const name = yield* validateName(input.name, "Name") const slug = yield* validateSlug(input.slug) const description = yield* validateText(input.description, "Description") @@ -1363,11 +1410,39 @@ export class StatusPagesService extends Context.Service (c.monitorId === null ? [] : [c.monitorId]))), ] + // The document as it will be stored; new components have no id yet. + const written: StatusPageConfig = { + name, + slug, + description, + website_url: websiteUrl, + logo_url: logoUrl, + show_powered_by: input.show_powered_by, + auto_incidents: input.auto_incidents, + theme, + components: components.map((component) => ({ + ...(component.id === null ? undefined : { id: component.id }), + name: component.name, + description: component.description, + group: component.groupName, + monitor_id: component.monitorId, + manual_status: component.manualStatus, + show_uptime: component.showUptime, + })), + } + const ifRevision = options?.ifRevision const now = yield* Clock.currentTimeMillis return yield* dbExecuteSlug(slug)((db) => db.transaction( Effect.gen(function* () { - const current = yield* findPage(db, orgId, pageId) + // Locked before the components are read, so the change set below is the write's. + const current = yield* lockPage(db, orgId, pageId) + if (ifRevision !== undefined && ifRevision !== current.revision) + return yield* new StatusPageConflictError({ + pageId, + revision: current.revision, + message: `The page changed since revision ${ifRevision} (it is now at ${current.revision}). Read the config again and redo the edit on it.`, + }) const existing = yield* selectComponents(db, orgId, pageId) const byId = new Map(existing.map((row) => [row.id, row])) const foreign = listedIds.find((id) => !byId.has(id)) @@ -1377,6 +1452,11 @@ export class StatusPagesService extends Context.Service = [ "@maple/http/errors/SourceNotFoundError", "@maple/http/errors/SourceValidationError", "@maple/http/errors/StatusPageComponentNotFoundError", + "@maple/http/errors/StatusPageConflictError", "@maple/http/errors/StatusPageIncidentNotFoundError", "@maple/http/errors/StatusPageNotFoundError", "@maple/http/errors/StatusPageSlugTakenError", diff --git a/packages/domain/src/http/status-pages.ts b/packages/domain/src/http/status-pages.ts index dd269bf8ee..76ce96b620 100644 --- a/packages/domain/src/http/status-pages.ts +++ b/packages/domain/src/http/status-pages.ts @@ -104,6 +104,25 @@ export class StatusPageSlugTakenError extends HttpTaggedError()( + "@maple/http/errors/StatusPageConflictError", + { + pageId: StatusPageId, + revision: Schema.Number, + message: Schema.String, + }, + { + status: 409, + code: "status_page_conflict", + title: "Status page changed", + param: "if_revision", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + export class StatusPagePersistenceError extends HttpTaggedError()( "@maple/http/errors/StatusPagePersistenceError", { diff --git a/packages/domain/src/http/v2/status-page-config.test.ts b/packages/domain/src/http/v2/status-page-config.test.ts index b83e4800f6..7a9f94904f 100644 --- a/packages/domain/src/http/v2/status-page-config.test.ts +++ b/packages/domain/src/http/v2/status-page-config.test.ts @@ -91,6 +91,24 @@ describe("diffStatusPageConfig", () => { }) }) + it("reads text and colours as an apply stores them, so padding or case is no change", () => { + const changes = diffStatusPageConfig( + { ...current, theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: "#1a2b3c" } }, + { + ...current, + name: " Acme ", + description: " ", + theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: " #1A2B3C" }, + components: current.components.map((component) => ({ + ...component, + name: ` ${component.name} `, + group: "", + })), + }, + ) + expect(changes).toMatchObject({ fields: [], updated: [] }) + }) + it("flags ids that are not on the page and ids listed twice", () => { const changes = diffStatusPageConfig(current, { ...current, @@ -111,4 +129,15 @@ describe("diffStatusPageConfig", () => { expect(decode(encoded)).toEqual(current) expect(() => decode({ name: "Acme", slug: "acme", components: [{ name: "" }] })).toThrow() }) + + it("refuses more than 200 components", () => { + const decode = Schema.decodeUnknownExit(StatusPageConfig) + const document = (count: number) => ({ + name: "Acme", + slug: "acme", + components: Array.from({ length: count }, (_, index) => ({ name: `C${index}` })), + }) + expect(decode(document(200))._tag).toBe("Success") + expect(decode(document(201))._tag).toBe("Failure") + }) }) diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 4a5b9d33ad..381867c393 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" import { StatusPageAutoIncidents, @@ -21,6 +21,7 @@ import { } from "../../status-page" import { StatusPageComponentNotFoundError, + StatusPageConflictError, StatusPageIncidentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, @@ -469,7 +470,17 @@ export const StatusPageConfigComponent = Schema.Struct({ }) export type StatusPageConfigComponent = Schema.Schema.Type +/** Most components one page may have; the config and a single create both refuse more. */ +export const MAX_STATUS_PAGE_COMPONENTS = 200 + export const StatusPageConfig = Schema.Struct({ + revision: Schema.optionalKey( + Schema.Number.annotate({ + description: + "The page revision this document was read at. Read-only: an apply ignores it; send it back as `if_revision` to refuse the apply if the page changed since.", + examples: [4], + }), + ), name: NonEmptyString.annotate({ description: "Page title.", examples: ["Acme Status"] }), slug: slugField, description: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -480,10 +491,11 @@ export const StatusPageConfig = Schema.Struct({ ), auto_incidents: Schema.optionalKey(autoIncidentsField), theme: Schema.optionalKey(StatusPageConfigTheme), - components: Schema.Array(StatusPageConfigComponent).annotate({ - description: - "Every component, in display order. Listed with an `id` updates it, without one adds it, and a component left out is deleted.", - }), + components: Schema.Array(StatusPageConfigComponent) + .check(Schema.isMaxLength(MAX_STATUS_PAGE_COMPONENTS)) + .annotate({ + description: `Every component, in display order (at most ${MAX_STATUS_PAGE_COMPONENTS}). Listed with an \`id\` updates it, without one adds it, and a component left out is deleted.`, + }), }).annotate({ identifier: "StatusPageConfig", title: "Status page config", @@ -493,26 +505,48 @@ export const StatusPageConfig = Schema.Struct({ }) export type StatusPageConfig = Schema.Schema.Type -/** A config with every default filled in, the shape a read returns. */ -export const normalizeStatusPageConfig = (config: StatusPageConfig) => ({ - name: config.name, - slug: config.slug, - description: config.description ?? null, - website_url: config.website_url ?? null, - logo_url: config.logo_url ?? null, - show_powered_by: config.show_powered_by ?? true, - auto_incidents: config.auto_incidents ?? "off", - theme: { ...DEFAULT_STATUS_PAGE_THEME, ...config.theme }, - components: config.components.map((component) => ({ - ...(component.id === undefined ? undefined : { id: component.id }), - name: component.name, - description: component.description ?? null, - group: component.group ?? null, - monitor_id: component.monitor_id ?? null, - manual_status: component.manual_status ?? null, - show_uptime: component.show_uptime ?? true, - })), -}) +/** Optional text as the service stores it: trimmed, blank as `null`. */ +const textOrNull = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() ?? "" + return trimmed.length === 0 ? null : trimmed +} + +const hexOrNull = (value: string | null | undefined): string | null => + textOrNull(value)?.toLowerCase() ?? null + +/** + * A config as an apply would store it: defaults filled in, text trimmed, blank text `null`, + * hex colours lower-cased. The apply and every preview of it diff through this. + */ +export const normalizeStatusPageConfig = (config: StatusPageConfig) => { + const theme = { ...DEFAULT_STATUS_PAGE_THEME, ...config.theme } + return { + name: config.name.trim(), + slug: config.slug, + description: textOrNull(config.description), + website_url: textOrNull(config.website_url), + logo_url: textOrNull(config.logo_url), + show_powered_by: config.show_powered_by ?? true, + auto_incidents: config.auto_incidents ?? "off", + theme: { + ...theme, + accent: hexOrNull(theme.accent), + background: hexOrNull(theme.background), + text: hexOrNull(theme.text), + card: hexOrNull(theme.card), + border: hexOrNull(theme.border), + }, + components: config.components.map((component) => ({ + ...(component.id === undefined ? undefined : { id: component.id }), + name: component.name.trim(), + description: textOrNull(component.description), + group: textOrNull(component.group), + monitor_id: component.monitor_id ?? null, + manual_status: component.manual_status ?? null, + show_uptime: component.show_uptime ?? true, + })), + } +} const PAGE_CONFIG_FIELDS = [ "name", @@ -534,12 +568,16 @@ const COMPONENT_CONFIG_FIELDS = [ "show_uptime", ] as const -export interface StatusPageConfigChanges { +/** What an apply changed: page fields, and components by name. */ +export interface StatusPageConfigChangeSet { readonly fields: ReadonlyArray<(typeof PAGE_CONFIG_FIELDS)[number]> readonly created: ReadonlyArray readonly updated: ReadonlyArray readonly deleted: ReadonlyArray readonly reordered: boolean +} + +export interface StatusPageConfigChanges extends StatusPageConfigChangeSet { /** Ids listed that are not components of the page, or listed twice: the apply refuses both. */ readonly unknownIds: ReadonlyArray readonly duplicateIds: ReadonlyArray @@ -759,14 +797,16 @@ const IncidentsQuery = Schema.Struct({ description: "Pagination plus optional page and state filters.", }) -const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, persistence] = publicErrors( - StatusPageNotFoundError, - StatusPageComponentNotFoundError, - StatusPageIncidentNotFoundError, - StatusPageValidationError, - StatusPageSlugTakenError, - StatusPagePersistenceError, -) +const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, conflict, persistence] = + publicErrors( + StatusPageNotFoundError, + StatusPageComponentNotFoundError, + StatusPageIncidentNotFoundError, + StatusPageValidationError, + StatusPageSlugTakenError, + StatusPageConflictError, + StatusPagePersistenceError, + ) const StatusPageList = ListOf(V2StatusPage).annotate({ identifier: "StatusPageList", @@ -780,6 +820,19 @@ const StatusPageIncidentList = ListOf(V2StatusPageIncident).annotate({ description: "A cursor-paginated page of status page incidents, newest first.", }) +const ApplyConfigQuery = Schema.Struct({ + if_revision: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)).annotate({ + description: + "The `revision` the document was read at. When the page has moved past it, the apply answers 409 `status_page_conflict` and changes nothing.", + }), + ), +}).annotate({ + identifier: "StatusPageConfigApplyQuery", + title: "Status page config apply query", + description: "An optional precondition on the page revision.", +}) + const pageParams = { id: StatusPagePublicId } const componentParams = { id: StatusPagePublicId, component_id: StatusPageComponentPublicId } const adminNote = "Requires the `status_pages:write` scope and an org-admin caller." @@ -902,23 +955,35 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") identifier: "getStatusPageConfig", summary: "Retrieve a status page config", description: - "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes. Requires the `status_pages:read` scope.", + "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes, with the `revision` it was read at. Requires the `status_pages:read` scope.", }), ), ) .add( HttpApiEndpoint.put("applyConfig", "/:id/config", { params: pageParams, + query: ApplyConfigQuery, payload: StatusPageConfig, success: StatusPageConfig, - error: [V2InsufficientPermissions.schema, pageNotFound, validation, slugTaken, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "applyStatusPageConfig", - summary: "Apply a status page config", - description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part rejects the whole document. Returns the applied config. ${adminNote}`, - }), - ), + error: [ + V2InsufficientPermissions.schema, + V2ParameterInvalid.schema, + pageNotFound, + validation, + slugTaken, + conflict, + persistence, + ], + }) + // A misspelt key would otherwise be dropped and its field reset to the default. + .annotate(HttpApi.PayloadParseOptions, { onExcessProperty: "error" }) + .annotateMerge( + OpenApi.annotations({ + identifier: "applyStatusPageConfig", + summary: "Apply a status page config", + description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part, or a key the document does not define, rejects the whole document. With \`if_revision\`, a page that changed since that revision answers 409 and nothing is written. Returns the applied config. ${adminNote}`, + }), + ), ) .add( HttpApiEndpoint.get("listComponents", "/:id/components", { @@ -945,7 +1010,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") OpenApi.annotations({ identifier: "createStatusPageComponent", summary: "Add a status page component", - description: `Adds a component after the existing ones. ${adminNote}`, + description: `Adds a component after the existing ones; a page holds at most ${MAX_STATUS_PAGE_COMPONENTS}. ${adminNote}`, }), ), ) From 8068d8c71e07c3ccaf2ad9fb61c0bc4d56b70431 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 09:34:10 +0100 Subject: [PATCH 07/17] fix(web): JSON tab reads GET /config and applies against its revision The tab's document came from the components list, which stops at 100, so Apply deleted the rest. It now loads its own keyed config read (loading and error states like the other tabs, re-read when the tab opens) and sends if_revision; a conflict re-reads the config. Typing is ignored and the editor disabled while an apply runs, Settings cannot save meanwhile, Settings edits typed during the apply survive it, and incidents re-read after it. --- .../alerts/shared/status-page-commands.ts | 20 +- .../alerts/status-pages/editor/command.ts | 17 +- .../alerts/status-pages/editor/config-json.ts | 36 +--- .../status-pages/editor/editor.scene.test.ts | 192 +++++++++++++++++- .../alerts/status-pages/editor/message.ts | 1 + .../page/alerts/status-pages/editor/model.ts | 3 + .../page/alerts/status-pages/editor/reads.ts | 19 +- .../alerts/status-pages/editor/update-json.ts | 78 +++++-- .../status-pages/editor/update-settings.ts | 1 + .../page/alerts/status-pages/editor/update.ts | 29 ++- .../alerts/status-pages/editor/view-json.ts | 1 + .../status-pages/editor/view-settings.ts | 8 +- .../page/alerts/status-pages/editor/view.ts | 13 +- .../page/alerts/status-pages/test-pages.ts | 32 ++- 14 files changed, 386 insertions(+), 64 deletions(-) diff --git a/apps/web/src/page/alerts/shared/status-page-commands.ts b/apps/web/src/page/alerts/shared/status-page-commands.ts index cedc754d66..8da68de166 100644 --- a/apps/web/src/page/alerts/shared/status-page-commands.ts +++ b/apps/web/src/page/alerts/shared/status-page-commands.ts @@ -150,11 +150,25 @@ export const reorderComponents = (id: StatusPageId, componentIds: ReadonlyArray< return response.data }).pipe(Effect.mapError(toLoadFailure)) -/** Replaces the page with `config` in one write and returns the applied document. */ -export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig) => +/** The page as one document, with the revision it was read at. */ +export const retrievePageConfig = (id: StatusPageId) => Effect.gen(function* () { const client = yield* MapleApiV2Client - return yield* client.statusPages.applyConfig({ params: { id }, payload: config }) + return yield* client.statusPages.getConfig({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** + * Replaces the page with `config` in one write and returns the applied document. A page past + * `ifRevision` refuses it, so a stale document never deletes what someone else added. + */ +export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig, ifRevision: number | undefined) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.statusPages.applyConfig({ + params: { id }, + query: { if_revision: ifRevision }, + payload: config, + }) }).pipe(Effect.mapError(toLoadFailure)) export const createIncident = (payload: V2StatusPageIncidentCreateParams) => diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/alerts/status-pages/editor/command.ts index cada0e5e16..27c8b280bd 100644 --- a/apps/web/src/page/alerts/status-pages/editor/command.ts +++ b/apps/web/src/page/alerts/status-pages/editor/command.ts @@ -20,6 +20,7 @@ import { publishPage, reorderComponents, retrievePage, + retrievePageConfig, revokeBreakGlass, rotateBreakGlass, saveComponent, @@ -185,11 +186,21 @@ export const RevokeToken = Command.define("RevokeStatusPageBreakGlassToken", { ), }) +export const FetchConfig = Command.define("FetchStatusPageConfig", { + args: { key: Schema.String, pageId: StatusPageId }, + messages: [Message.CompletedFetchConfig], + execute: ({ key, pageId }) => + retrievePageConfig(pageId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchConfig({ key, result })), + ), +}) + export const ApplyConfig = Command.define("ApplyStatusPageConfig", { - args: { pageId: StatusPageId, config: StatusPageConfig }, + args: { pageId: StatusPageId, config: StatusPageConfig, ifRevision: Schema.UndefinedOr(Schema.Number) }, messages: [Message.CompletedApplyJson], - execute: ({ pageId, config }) => - applyPageConfig(pageId, config).pipe( + execute: ({ pageId, config, ifRevision }) => + applyPageConfig(pageId, config, ifRevision).pipe( Effect.result, Effect.map((result) => Message.CompletedApplyJson({ result })), ), diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.ts index a851b653cc..3b7afa8c73 100644 --- a/apps/web/src/page/alerts/status-pages/editor/config-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.ts @@ -3,43 +3,19 @@ import { StatusPageComponentPublicId, StatusPageConfig, type StatusPageConfigChanges, - type V2StatusPage, - type V2StatusPageComponent, } from "@maple/domain/http/v2" import { Result, Schema, SchemaIssue } from "effect" -// The JSON tab's document: the loaded page as a `StatusPageConfig`, and a draft parsed back. - -/** The same document `GET /v2/status_pages/{id}/config` returns, from the reads already loaded. */ -export const configOf = ( - page: V2StatusPage, - components: ReadonlyArray, -): StatusPageConfig => ({ - name: page.name, - slug: page.slug, - description: page.description, - website_url: page.website_url, - logo_url: page.logo_url, - show_powered_by: page.show_powered_by, - auto_incidents: page.auto_incidents, - theme: page.theme, - components: components.map((component) => ({ - id: component.id, - name: component.name, - description: component.description, - group: component.group, - monitor_id: component.monitor_id, - manual_status: component.manual_status, - show_uptime: component.show_uptime, - })), -}) +// The JSON tab's document: the page as `GET …/config` returns it, and a draft parsed back. const encodeConfig = Schema.encodeSync(StatusPageConfig) const encodeId = Schema.encodeSync(StatusPageComponentPublicId) -/** Pretty-printed with public ids, two-space indent. */ -export const formatConfig = (config: StatusPageConfig): string => - JSON.stringify(encodeConfig(config), null, 2) +/** Pretty-printed with public ids, two-space indent; the revision rides along as `if_revision`. */ +export const formatConfig = (config: StatusPageConfig): string => { + const { revision: _revision, ...document } = config + return JSON.stringify(encodeConfig(document), null, 2) +} export type DraftProblem = | { readonly _tag: "Syntax"; readonly message: string; readonly line: number; readonly column: number } diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 767ec1cf9e..dd6e6e47f9 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -1,5 +1,10 @@ import { OrgId } from "@maple/domain" -import { StatusPageComponentPublicId, StatusPagePublicId, type V2StatusPage } from "@maple/domain/http/v2" +import { + StatusPageComponentPublicId, + type StatusPageConfig, + StatusPagePublicId, + type V2StatusPage, +} from "@maple/domain/http/v2" import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" import { Option, Result, Schema } from "effect" import { @@ -25,10 +30,10 @@ import { PageOutMessage, type PageContext } from "../../../contract" import { statusPagesTabHref } from "../../shared/status-page-commands" import { ORG } from "../../shared/test-rows" import { downMonitor, httpMonitor } from "../../shared/uptime-test-monitors" -import { component, componentId, incident, incidentId, statusPage } from "../test-pages" +import { component, componentId, incident, incidentId, statusPage, statusPageConfig } from "../test-pages" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { moved } from "./update-components" import { view as pageView } from "./view" @@ -48,6 +53,9 @@ type Fixture = Readonly<{ incidents?: ReturnType[] monitorsFail?: boolean componentsFail?: boolean + /** The config read's document; by default the page and its components. */ + config?: StatusPageConfig + configFail?: boolean }> const loaded = (fixture: Fixture = {}): Model => { @@ -82,6 +90,23 @@ const loaded = (fixture: Fixture = {}): Model => { ? Result.fail(toLoadFailure(new Error("monitors unavailable"))) : Result.succeed([httpMonitor(), downMonitor()]), }), + // Only the JSON tab reads the config. + ...(entered.config.key === null + ? [] + : [ + Message.CompletedFetchConfig({ + key: entered.config.key, + result: fixture.configFail + ? Result.fail(toLoadFailure(new Error("config unavailable"))) + : Result.succeed( + fixture.config ?? + statusPageConfig( + fixture.page ?? statusPage(), + fixture.components ?? [component(1), component(2), component(3)], + ), + ), + }), + ]), ].reduce((model, message) => pageUpdate(model, message).model, entered) } @@ -705,6 +730,167 @@ describe("json tab", () => { ) }) + test("the document comes from the config read, so components past the list's first page stay", () => { + const entered = init( + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ) + expect(names(entered.commands)).toContain("FetchStatusPageConfig") + const listed = [component(1), component(2)] + const config = statusPageConfig(statusPage(), [...listed, component(3), component(4)]) + const model = loaded({ tab: "json", components: listed, config }) + scene( + { update: pageUpdate, view }, + given(model), + expectView(selector("pre")).toContainText(`"id": "${publicComponentId(4)}"`), + click(role("button", { name: "Edit JSON" })), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + const dropped = JSON.stringify({ ...documentOf([1, 2, 3]) }) + const editingConfig = [Message.ClickedEditJson(), Message.ChangedJson({ value: dropped })].reduce( + (current, message) => pageUpdate(current, message).model, + model, + ) + scene( + { update: pageUpdate, view }, + given(editingConfig), + expectView(text("Components to delete")).toExist(), + expectView(text("Component 4")).toExist(), + ) + }) + + test("the tab shows the config read's loading and failure states", () => { + const entered = init( + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ).model + const pageIn = pageUpdate( + entered, + Message.CompletedFetchPage({ key: entered.page.key ?? "", result: Result.succeed(statusPage()) }), + ).model + scene( + { update: pageUpdate, view }, + given(pageIn), + expectView(selector("pre")).not.toExist(), + expectView(role("button", { name: "Edit JSON" })).not.toExist(), + ) + const failed = loaded({ tab: "json", configFail: true }) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text("Couldn't load the config")).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetry()).commands)).toContain("FetchStatusPageConfig") + }) + + test("opening the tab re-reads the config, since other tabs write the page too", () => { + const onSettings = loaded({ tab: "settings" }) + expect(onSettings.config.key).toBeNull() + const opened = routeChanged( + onSettings, + { _tag: "AlertsStatusPage", pageId: PUBLIC_ID, search: { tab: "json" } }, + context, + ) + expect(names(opened.commands)).toEqual(["FetchStatusPageConfig"]) + }) + + test("Apply sends the revision the document was read at", () => { + const model = editing(JSON.stringify({ ...documentOf([1, 2]) }), { + config: { + ...statusPageConfig(statusPage(), [component(1), component(2), component(3)]), + revision: 7, + }, + }) + expect(pageUpdate(model, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifRevision: 7 } }, + ]) + }) + + test("a draft that differs only by spacing or colour case is no change", () => { + const theme = { ...DEFAULT_STATUS_PAGE_THEME, appearance: "light" as const, accent: "#1a2b3c" } + const page = statusPage({ description: "Live status.", theme }) + const draft = JSON.stringify({ + ...documentOf([1, 2, 3]), + name: " Acme Status ", + description: " Live status. ", + auto_incidents: "draft", + theme: { ...theme, accent: "#1A2B3C" }, + components: documentOf([1, 2, 3]).components.map((c) => ({ + ...c, + name: ` ${c.name}`, + group: " ", + })), + }) + scene( + { update: pageUpdate, view }, + given(editing(draft, { page })), + expectView(text("No changes yet.")).toExist(), + expectView(role("button", { name: "Apply" })).toBeDisabled(), + ) + }) + + test("while an apply runs, typing is ignored, the editor is disabled, and Settings cannot save", () => { + const draft = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draft), Message.ClickedApplyJson()).model + expect(applying.isApplyingJson).toBe(true) + expect(pageUpdate(applying, Message.ChangedJson({ value: "{}" })).model.jsonDraft).toBe(draft) + scene( + { update: pageUpdate, view }, + given(applying), + expectView(role("textbox", { name: "Config JSON" })).toBeDisabled(), + ) + const edited = pageUpdate( + applying, + Message.ChangedSetting({ field: "name", value: "Typed meanwhile" }), + ).model + expect(pageUpdate(edited, Message.ClickedSaveSettings()).commands ?? []).toEqual([]) + }) + + test("a successful apply keeps Settings edits typed meanwhile and re-reads incidents and the config", () => { + const draft = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draft), Message.ClickedApplyJson()).model + const typed = pageUpdate( + applying, + Message.ChangedSetting({ field: "name", value: "Typed meanwhile" }), + ).model + const applied = (model: Model) => + pageUpdate( + model, + Message.CompletedApplyJson({ + result: Result.succeed({ ...documentOf([1]), components: [], revision: 4 }), + }), + ) + const kept = applied(typed) + expect(kept.model.settingsEdits?.name).toBe("Typed meanwhile") + expect(names(kept.commands)).toEqual( + expect.arrayContaining(["FetchStatusPageIncidents", "FetchStatusPageConfig"]), + ) + expect(applied(applying).model.settingsEdits).toBeNull() + }) + + test("a conflict keeps the draft, says why and re-reads the config", () => { + const reason = "The page changed since revision 3 (it is now at 4)." + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1]))), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error(reason)), + code: "status_page_conflict", + message: reason, + retryable: false, + }), + }), + ) + expect(refused.model.jsonDraft).toBe(applying.jsonDraft) + expect(names(refused.commands)).toEqual(["FetchStatusPageConfig"]) + scene({ update: pageUpdate, view }, given(refused.model), expectView(text(reason)).toExist()) + }) + test("unsaved Settings edits block Apply until they are saved or discarded", () => { const draft = JSON.stringify(documentOf([1])) const dirty = pageUpdate( diff --git a/apps/web/src/page/alerts/status-pages/editor/message.ts b/apps/web/src/page/alerts/status-pages/editor/message.ts index bc5dd61f86..252ae92822 100644 --- a/apps/web/src/page/alerts/status-pages/editor/message.ts +++ b/apps/web/src/page/alerts/status-pages/editor/message.ts @@ -91,6 +91,7 @@ export const Message = defineMessageUnion({ CompletedDeletePage: { result: outcome(Schema.Void) }, GotDeletePageDialogMessage: { message: Dialog.Message }, // JSON + CompletedFetchConfig: { key: Schema.String, result: outcome(StatusPageConfig) }, ClickedEditJson: {}, ChangedJson: { value: Schema.String }, ClickedResetJson: {}, diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts index b2fbe9f0af..7f403a3001 100644 --- a/apps/web/src/page/alerts/status-pages/editor/model.ts +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -1,5 +1,6 @@ import { StatusPageComponentId, StatusPageId, StatusPageIncidentId } from "@maple/domain" import { + StatusPageConfig, V2StatusPage, V2StatusPageComponent, V2StatusPageIncident, @@ -73,6 +74,8 @@ export const Model = Schema.Struct({ deletePageDialog: Dialog.Model, isDeletingPage: Schema.Boolean, // JSON + /** The page as `GET …/config` returns it: every component, and the revision to apply against. */ + config: KeyedSchema(StatusPageConfig, LoadFailure), /** The edited document; `null` while the tab shows the page as saved. */ jsonDraft: Schema.NullOr(Schema.String), isApplyingJson: Schema.Boolean, diff --git a/apps/web/src/page/alerts/status-pages/editor/reads.ts b/apps/web/src/page/alerts/status-pages/editor/reads.ts index 84420f3a88..53a42ca291 100644 --- a/apps/web/src/page/alerts/status-pages/editor/reads.ts +++ b/apps/web/src/page/alerts/status-pages/editor/reads.ts @@ -6,7 +6,7 @@ import { requestKey } from "../../../../data/request-key" import * as Dialog from "../../../../ui/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../../contract" import { UPTIME_LIST_LIMIT } from "../../shared/uptime-commands" -import { FetchComponents, FetchEditorMonitors, FetchIncidents, FetchPage } from "./command" +import { FetchComponents, FetchConfig, FetchEditorMonitors, FetchIncidents, FetchPage } from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -18,6 +18,8 @@ const pageKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPages.retrieve", { pageId }) const componentsKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPages.listComponents", { pageId }) +const configKey = (context: PageContext, pageId: string) => + requestKey(context.orgId, "statusPages.getConfig", { pageId }) const incidentsKey = (context: PageContext, pageId: string) => requestKey(context.orgId, "statusPageIncidents.list", { pageId }) const monitorsKey = (context: PageContext) => @@ -65,6 +67,21 @@ export const readComponents = }) } +/** The JSON tab's document; read only while that tab is open, since it lists every component. */ +export const readConfig = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => { + const id = model.pageId + if (id === null) return { model } + return Option.match(Keyed.requestFor(model.config, configKey(context, id), mode), { + onNone: () => ({ model }), + onSome: (config) => ({ + model: { ...model, config }, + commands: [FetchConfig({ key: config.key, pageId: id })], + }), + }) + } + export const readIncidents = (context: PageContext, mode: Keyed.ReadMode) => (model: Model): Return => { diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index 2a1bf3a48e..b57e88648e 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -1,23 +1,31 @@ -import type { StatusPageConfig } from "@maple/domain/http/v2" +import { normalizeStatusPageConfig, type StatusPageConfig, type V2StatusPage } from "@maple/domain/http/v2" import { Option, Result } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import type { LoadFailure } from "../../../../data/load-failure" import * as CopyButton from "../../../../ui/copy-button" import type { PageContext } from "../../../contract" import { ApplyConfig } from "./command" -import { configOf, type DraftResult, formatConfig, parseDraft } from "./config-json" +import { type DraftResult, formatConfig, parseDraft } from "./config-json" +import { isSameSettings, settingsFormOf } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { chain, componentsOf, pageOf, readComponents, readPage, type Return, toast } from "./reads" +import { + chain, + pageOf, + readComponents, + readConfig, + readIncidents, + readPage, + type Return, + toast, +} from "./reads" import { isSettingsDirty } from "./update-settings" // The JSON tab: the page as one document, and an admin's edit of it applied in one write. -/** The saved page as its config, once the page and its components are in. */ +/** The saved page as `GET …/config` returned it, with every component and its revision. */ export const currentConfigOf = (model: Model): Option.Option => - AsyncData.hasData(model.components.data) - ? Option.map(pageOf(model), (page) => configOf(page, componentsOf(model))) - : Option.none() + AsyncData.getData(model.config.data) /** The draft parsed against the saved page; none while not editing. */ export const draftOf = (model: Model): Option.Option => @@ -40,6 +48,7 @@ const hasChanges = (draft: DraftResult): boolean => export const canApply = (model: Model): boolean => model.isAdmin && !model.isApplyingJson && + !model.isSavingSettings && !isSettingsDirty(model) && Option.match(draftOf(model), { onNone: () => false, onSome: hasChanges }) @@ -55,8 +64,11 @@ export const editJson = (model: Model): Return => ? withCurrent(model, (jsonDraft) => ({ ...model, jsonDraft, jsonFailure: null })) : { model } +/** Ignored while an apply runs: its result replaces the draft, and would drop the keystrokes. */ export const changeJson = (model: Model, value: string): Return => - model.jsonDraft === null ? { model } : { model: { ...model, jsonDraft: value, jsonFailure: null } } + model.jsonDraft === null || model.isApplyingJson + ? { model } + : { model: { ...model, jsonDraft: value, jsonFailure: null } } export const cancelJson = (model: Model): Return => model.isApplyingJson ? { model } : { model: { ...model, jsonDraft: null, jsonFailure: null } } @@ -64,6 +76,7 @@ export const cancelJson = (model: Model): Return => export const applyJson = (model: Model): Return => { const pageId = model.pageId if (pageId === null || !canApply(model)) return { model } + const ifRevision = Option.getOrUndefined(currentConfigOf(model))?.revision return Option.match(draftOf(model), { onNone: () => ({ model }), onSome: (draft) => @@ -71,27 +84,68 @@ export const applyJson = (model: Model): Return => { onFailure: () => ({ model }), onSuccess: ({ config }) => ({ model: { ...model, isApplyingJson: true, jsonFailure: null }, - commands: [ApplyConfig({ pageId, config })], + commands: [ApplyConfig({ pageId, config, ifRevision })], }), }), }) } +/** The loaded page with the applied document's settings, to compare Settings edits against. */ +const pageWith = (page: V2StatusPage, applied: StatusPageConfig): V2StatusPage => { + const config = normalizeStatusPageConfig(applied) + return { + ...page, + name: config.name, + slug: config.slug, + description: config.description, + website_url: config.website_url, + logo_url: config.logo_url, + show_powered_by: config.show_powered_by, + auto_incidents: config.auto_incidents, + theme: config.theme, + } +} + +/** + * Settings edits survive an apply when they are real edits: ones that match neither the page the + * apply replaced nor the one it wrote were typed meanwhile, and are kept. + */ +const settingsAfterApply = (model: Model, applied: StatusPageConfig): Model["settingsEdits"] => { + const edits = model.settingsEdits + if (edits === null || !isSettingsDirty(model)) return null + return Option.match(pageOf(model), { + onNone: () => edits, + onSome: (page) => (isSameSettings(edits, settingsFormOf(pageWith(page, applied))) ? null : edits), + }) +} + export const appliedJson = ( model: Model, result: Result.Result, context: PageContext, ): Return => Result.match(result, { - onSuccess: (): Return => ({ + onSuccess: (applied): Return => ({ + // Deleted components take their incident links with them, so incidents re-read too. ...chain( - { ...model, isApplyingJson: false, jsonDraft: null, settingsEdits: null }, + { + ...model, + isApplyingJson: false, + jsonDraft: null, + settingsEdits: settingsAfterApply(model, applied), + }, readPage(context, "Supersede"), readComponents(context, "Supersede"), + readIncidents(context, "Supersede"), + readConfig(context, "Supersede"), ), outMessage: toast("success", "Config applied", "It goes out with the next publish."), }), - onFailure: (jsonFailure): Return => ({ model: { ...model, isApplyingJson: false, jsonFailure } }), + // A conflict means the page moved on: re-read it so Reset starts from the current one. + onFailure: (jsonFailure): Return => + jsonFailure.code === "status_page_conflict" + ? chain({ ...model, isApplyingJson: false, jsonFailure }, readConfig(context, "Supersede")) + : { model: { ...model, isApplyingJson: false, jsonFailure } }, }) export const updateJsonCopy = (model: Model, message: CopyButton.Message): Return => { diff --git a/apps/web/src/page/alerts/status-pages/editor/update-settings.ts b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts index 808a193e01..a61a5bf51a 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-settings.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-settings.ts @@ -71,6 +71,7 @@ export const saveSettings = (model: Model): Return => { return model.pageId === null || edits === null || model.isSavingSettings || + model.isApplyingJson || !model.isAdmin || hasSettingsIssues(edits) ? { model } diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 46e69d73dc..61cc0b1f8f 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -9,7 +9,15 @@ import { FetchEditorSession, ReadEditorClock } from "./command" import { emptyComponentForm, emptyIncidentForm } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { chain, readComponents, readIncidents, readMonitors, readPage, type Return } from "./reads" +import { + chain, + readComponents, + readConfig, + readIncidents, + readMonitors, + readPage, + type Return, +} from "./reads" import { askDeleteComponent, changeComponent, @@ -73,6 +81,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { page: Keyed.idle(), components: Keyed.idle(), incidents: Keyed.idle(), + config: Keyed.idle(), monitors: Keyed.idle(), isAdmin: false, isSessionLoaded: false, @@ -116,6 +125,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { readComponents(context, "Load"), readIncidents(context, "Load"), readMonitors(context, "Load"), + whenJson(context, "Load"), ) return { model: reads.model, @@ -127,10 +137,15 @@ export const init = (route: EditorRoute, context: PageContext): Return => { } } -/** A tab switch is a search change: the reads are already in the Model. */ -export const routeChanged = (model: Model, route: EditorRoute): Return => ({ - model: { ...model, search: route.search }, -}) +/** The config read, only while the JSON tab is open: the document must be current when edited. */ +const whenJson = + (context: PageContext, mode: Keyed.ReadMode) => + (model: Model): Return => + resolveTab(model.search.tab) === "json" ? readConfig(context, mode)(model) : { model } + +/** A tab switch is a search change; opening JSON re-reads the config, since other tabs write too. */ +export const routeChanged = (model: Model, route: EditorRoute, context: PageContext): Return => + whenJson(context, "Refresh")({ ...model, search: route.search }) /** The dialogs ignore dismissal while their write is in flight, as `ConfirmDialog` does. */ const whenIdle = (isBusy: boolean, model: Model, run: () => Return): Return => (isBusy ? { model } : run()) @@ -150,6 +165,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, incidents: Keyed.accept(model.incidents, key, result) }, commands: [ReadEditorClock()], }), + CompletedFetchConfig: ({ key, result }) => ({ + model: { ...model, config: Keyed.accept(model.config, key, result) }, + }), CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, }), @@ -162,6 +180,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re readComponents(context, "Supersede"), readIncidents(context, "Supersede"), readMonitors(context, "Supersede"), + whenJson(context, "Supersede"), ), // Components ClickedAddComponent: () => openComponent(model, null), diff --git a/apps/web/src/page/alerts/status-pages/editor/view-json.ts b/apps/web/src/page/alerts/status-pages/editor/view-json.ts index 5179988e1a..e33440f630 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view-json.ts @@ -141,6 +141,7 @@ const editView = (h: H, model: Model, draft: string): Html => { ), textarea(h, { id: "status-page-json", + isDisabled: model.isApplyingJson, controlClassName: "min-h-[28rem] font-mono text-xs leading-relaxed", attributes: [ h.Value(draft), diff --git a/apps/web/src/page/alerts/status-pages/editor/view-settings.ts b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts index 9ea078a43c..00cccc9065 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view-settings.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view-settings.ts @@ -312,10 +312,14 @@ export const settingsTab = (h: H, model: Model, page: V2StatusPage): Html => { ...saveBar(h, { isDirty: isSettingsDirty(model), isPending: model.isSavingSettings, - context: hasIssues ? "Fix the fields marked above." : undefined, + context: hasIssues + ? "Fix the fields marked above." + : model.isApplyingJson + ? "Saving waits for the JSON apply to finish." + : undefined, onDiscard: Message.ClickedDiscardSettings(), onSave: Message.ClickedSaveSettings(), - isSaveDisabled: hasIssues, + isSaveDisabled: hasIssues || model.isApplyingJson, }), ], ) diff --git a/apps/web/src/page/alerts/status-pages/editor/view.ts b/apps/web/src/page/alerts/status-pages/editor/view.ts index c2d691c1d5..1fd2e2a8cf 100644 --- a/apps/web/src/page/alerts/status-pages/editor/view.ts +++ b/apps/web/src/page/alerts/status-pages/editor/view.ts @@ -131,11 +131,11 @@ const tabRead = (model: Model, tab: EditorTab): { failure: Option.Option = {}): updated_at: iso(10 * MINUTE), ...fields, }) + +/** The document `GET …/config` returns for a page and its components. */ +export const statusPageConfig = ( + page: V2StatusPage, + components: ReadonlyArray, +): StatusPageConfig => ({ + revision: page.revision, + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.website_url, + logo_url: page.logo_url, + show_powered_by: page.show_powered_by, + auto_incidents: page.auto_incidents, + theme: page.theme, + components: components.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + group: row.group, + monitor_id: row.monitor_id, + manual_status: row.manual_status, + show_uptime: row.show_uptime, + })), +}) From ade9839ea67da7ba8b83c48b8395b1be990c891a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 11:58:00 +0100 Subject: [PATCH 08/17] fix(status): config apply precondition is a content version, not the page revision Monitor flips, reconciles and publishes bump the page revision, so an if_revision apply would 409 through every outage. GET /config now carries `version`, a cyrb53 hash of the normalized, canonically serialized document (statusPageConfigVersion, sync and identical in browser, workerd and Node). PUT /config?if_version= compares it, under the page lock, with the version of the config built from the locked rows, and answers 409 StatusPageConflictError only when the document's content changed. MCP apply defaults if_version to the document's version; the web tab sends the version from its config read. --- .../tools/__tests__/status-page-tools.test.ts | 9 ++- apps/ai/src/mcp/tools/status-pages.ts | 12 ++-- .../src/routes/v2/status-pages.http.test.ts | 20 +++--- apps/api/src/routes/v2/status-pages.http.ts | 6 +- .../alerts/shared/status-page-commands.ts | 7 +- .../alerts/status-pages/editor/command.ts | 6 +- .../alerts/status-pages/editor/config-json.ts | 4 +- .../status-pages/editor/editor.scene.test.ts | 10 +-- .../alerts/status-pages/editor/update-json.ts | 6 +- .../page/alerts/status-pages/test-pages.ts | 53 +++++++------- docs/api-v2.md | 2 +- .../status-pages/StatusPagesService.test.ts | 65 ++++++++++++++--- .../status-pages/StatusPagesService.ts | 34 ++++++--- packages/domain/src/http/status-pages.ts | 7 +- .../src/http/v2/status-page-config.test.ts | 69 ++++++++++++++++++- packages/domain/src/http/v2/status-pages.ts | 54 ++++++++++++--- 16 files changed, 265 insertions(+), 99 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 8efb928811..eabecf3d6f 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 @@ -350,7 +350,7 @@ describe("status page tools", () => { expect(after.structuredContent.config).toEqual(config) }) - it("refuses a document read before the page changed, and says to read it again", async () => { + it("refuses a document read before the config changed, and says to read it again", async () => { const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) const { config } = read.structuredContent await runToolDirect(admin(), "apply_status_page_config", { @@ -364,8 +364,15 @@ describe("status page tools", () => { }) expect(stale.isError).toBe(true) expect(markdown(stale)).toContain("get_status_page_config again") + const explicit = await runToolDirect(admin(), "apply_status_page_config", { + page: "config-page", + config: moved.structuredContent.config, + if_version: config.version, + }) + expect(explicit.isError).toBe(true) const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" }) expect(after.structuredContent.config).toEqual(moved.structuredContent.config) + expect(after.structuredContent.config.version).not.toBe(config.version) }) it("refuses members and names a component id from another page", async () => { diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index a3c5ba42f5..a620ad5707 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -73,7 +73,7 @@ const toMcpError = case "@maple/http/errors/StatusPageConflictError": return new McpInvalidInputError({ message: `${error.message} Nothing was applied. Call get_status_page_config again and redo the edit on the fresh document; applying the old one would undo the other change.`, - parameter: "if_revision", + parameter: "if_version", }) default: return new McpQueryError({ message: `${error._tag}: ${error.message}`, pipeName: tool }) @@ -612,7 +612,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { render: (output) => ({ title: `Status page config: ${output.page.name}`, notices: [ - "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order. Keep the `revision` key: the apply is refused if the page changed since this read.", + "apply_status_page_config replaces the page with an edited copy: keep a component's id to update it, drop the id to add one, leave it out to delete it. Order is display order. Keep the `version` key: the apply is refused if the config changed since this read.", ], blocks: [doc.code("json", JSON.stringify(encodeConfig(output.config), null, 2))], }), @@ -622,15 +622,15 @@ export function registerStatusPageTools(server: McpToolRegistrar) { name: "apply_status_page_config", title: "Apply Status Page Config", description: - "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults; unknown keys are refused. The document's `revision` guards the write: if the page changed since it was read, nothing is applied and you must read it again. Only org admins may call it, and the result is public on the page.", + "Replace a status page with a config document from get_status_page_config, in one write that fully applies or not at all. Components with an `id` are updated, without one created, and left out deleted; list order is display order; omitted optional fields take their defaults; unknown keys are refused. The document's `version` guards the write: if the config changed since it was read, nothing is applied and you must read it again. Only org admins may call it, and the result is public on the page.", parameters: Schema.Struct({ page: P.text("The page's slug, name or id"), config: P.json( StatusPageConfig, "The whole page document, as returned by get_status_page_config, edited. Keys it does not define are refused", ), - if_revision: P.optionalNumber( - "The page revision the document was read at. Defaults to the document's `revision`; the apply is refused if the page changed since", + if_version: P.optionalText( + "The config `version` the document was read with. Defaults to the document's `version`; the apply is refused if the config changed since", ), }), rejectUnknownKeys: true, @@ -652,7 +652,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { const page = yield* resolvePage(tool, tenant.orgId, params.page) const { config, changes } = yield* service .applyConfig(tenant.orgId, page.id, params.config, tenant.userId, { - ifRevision: params.if_revision ?? params.config.revision, + ifVersion: params.if_version ?? params.config.version, }) .pipe(Effect.mapError(toMcpError(tool))) const audit = yield* AuditLogService 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 993e55a787..88b0526232 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -492,7 +492,7 @@ describe("v2 status pages", () => { ], }) const before = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision - expect(read.body.revision).toBe(before) + expect(read.body.version).toMatch(/^[0-9a-z]+$/u) const applied = await harness.request( "PUT", @@ -501,7 +501,7 @@ describe("v2 status pages", () => { read.body, ) expect(applied.status).toBe(200) - expect(applied.body).toEqual({ ...read.body, revision: before + 1 }) + expect(applied.body).toEqual(read.body) const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision expect(after).toBe(before + 1) })) @@ -588,7 +588,7 @@ describe("v2 status pages", () => { expect((await harness.request("GET", path, token)).body).toEqual(read.body) })) - it("with a stale if_revision answers 409 and writes nothing; a current one applies", () => + it("with a stale if_version answers 409 and writes nothing; a current one applies", () => withHarness(async (harness) => { const { secret: token } = await harness.bootstrapKey() const { id } = await seedPage(harness, token, "acme-stale") @@ -596,20 +596,18 @@ describe("v2 status pages", () => { const read = await harness.request("GET", path, token) await harness.request("POST", `/v2/status_pages/${id}/components`, token, { name: "Added" }) const moved = await harness.request("GET", path, token) - const stale = await harness.request( - "PUT", - `${path}?if_revision=${read.body.revision}`, - token, - { ...read.body, name: "Stale" }, - ) + const stale = await harness.request("PUT", `${path}?if_version=${read.body.version}`, token, { + ...read.body, + name: "Stale", + }) expect(stale.status).toBe(409) expect(stale.body.error.code).toBe("status_page_conflict") - expect(stale.body.error.message).toContain(`revision ${read.body.revision}`) + expect(stale.body.error.message).toContain("changed since it was read") expect((await harness.request("GET", path, token)).body).toEqual(moved.body) const current = await harness.request( "PUT", - `${path}?if_revision=${moved.body.revision}`, + `${path}?if_version=${moved.body.version}`, token, { ...moved.body, name: "Fresh" }, ) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 3b45f8d4ce..eacbae30ba 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -349,10 +349,10 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag params.id, payload, tenant.userId, - { ifRevision: query.if_revision }, + { ifVersion: query.if_version }, ) - const { components: _before, revision: _beforeRevision, ...pageBefore } = previous - const { components: _after, revision: _afterRevision, ...pageAfter } = config + const { components: _before, version: _beforeVersion, ...pageBefore } = previous + const { components: _after, version: _afterVersion, ...pageAfter } = config yield* recordHttpAudit("status_page.config_applied", { resourceId: params.id, changes: diffAuditChanges(pageBefore, pageAfter), diff --git a/apps/web/src/page/alerts/shared/status-page-commands.ts b/apps/web/src/page/alerts/shared/status-page-commands.ts index 8da68de166..8cb44f3acc 100644 --- a/apps/web/src/page/alerts/shared/status-page-commands.ts +++ b/apps/web/src/page/alerts/shared/status-page-commands.ts @@ -159,14 +159,15 @@ export const retrievePageConfig = (id: StatusPageId) => /** * Replaces the page with `config` in one write and returns the applied document. A page past - * `ifRevision` refuses it, so a stale document never deletes what someone else added. + * `ifVersion` refuses it if the config changed since, so a stale document never deletes what + * someone else added. */ -export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig, ifRevision: number | undefined) => +export const applyPageConfig = (id: StatusPageId, config: StatusPageConfig, ifVersion: string | undefined) => Effect.gen(function* () { const client = yield* MapleApiV2Client return yield* client.statusPages.applyConfig({ params: { id }, - query: { if_revision: ifRevision }, + query: { if_version: ifVersion }, payload: config, }) }).pipe(Effect.mapError(toLoadFailure)) diff --git a/apps/web/src/page/alerts/status-pages/editor/command.ts b/apps/web/src/page/alerts/status-pages/editor/command.ts index 27c8b280bd..211037ef7a 100644 --- a/apps/web/src/page/alerts/status-pages/editor/command.ts +++ b/apps/web/src/page/alerts/status-pages/editor/command.ts @@ -197,10 +197,10 @@ export const FetchConfig = Command.define("FetchStatusPageConfig", { }) export const ApplyConfig = Command.define("ApplyStatusPageConfig", { - args: { pageId: StatusPageId, config: StatusPageConfig, ifRevision: Schema.UndefinedOr(Schema.Number) }, + args: { pageId: StatusPageId, config: StatusPageConfig, ifVersion: Schema.UndefinedOr(Schema.String) }, messages: [Message.CompletedApplyJson], - execute: ({ pageId, config, ifRevision }) => - applyPageConfig(pageId, config, ifRevision).pipe( + execute: ({ pageId, config, ifVersion }) => + applyPageConfig(pageId, config, ifVersion).pipe( Effect.result, Effect.map((result) => Message.CompletedApplyJson({ result })), ), diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.ts index 3b7afa8c73..599537a043 100644 --- a/apps/web/src/page/alerts/status-pages/editor/config-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.ts @@ -11,9 +11,9 @@ import { Result, Schema, SchemaIssue } from "effect" const encodeConfig = Schema.encodeSync(StatusPageConfig) const encodeId = Schema.encodeSync(StatusPageComponentPublicId) -/** Pretty-printed with public ids, two-space indent; the revision rides along as `if_revision`. */ +/** Pretty-printed with public ids, two-space indent; the version rides along as `if_version`. */ export const formatConfig = (config: StatusPageConfig): string => { - const { revision: _revision, ...document } = config + const { version: _version, ...document } = config return JSON.stringify(encodeConfig(document), null, 2) } diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index dd6e6e47f9..32a970a897 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -795,15 +795,15 @@ describe("json tab", () => { expect(names(opened.commands)).toEqual(["FetchStatusPageConfig"]) }) - test("Apply sends the revision the document was read at", () => { + test("Apply sends the version the document was read with", () => { const model = editing(JSON.stringify({ ...documentOf([1, 2]) }), { config: { ...statusPageConfig(statusPage(), [component(1), component(2), component(3)]), - revision: 7, + version: "v7", }, }) expect(pageUpdate(model, Message.ClickedApplyJson()).commands).toMatchObject([ - { name: "ApplyStatusPageConfig", args: { ifRevision: 7 } }, + { name: "ApplyStatusPageConfig", args: { ifVersion: "v7" } }, ]) }) @@ -858,7 +858,7 @@ describe("json tab", () => { pageUpdate( model, Message.CompletedApplyJson({ - result: Result.succeed({ ...documentOf([1]), components: [], revision: 4 }), + result: Result.succeed({ ...documentOf([1]), components: [], version: "v8" }), }), ) const kept = applied(typed) @@ -870,7 +870,7 @@ describe("json tab", () => { }) test("a conflict keeps the draft, says why and re-reads the config", () => { - const reason = "The page changed since revision 3 (it is now at 4)." + const reason = "The page's config changed since it was read." const applying = pageUpdate( editing(JSON.stringify(documentOf([1]))), Message.ClickedApplyJson(), diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index b57e88648e..7c2e75a222 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -23,7 +23,7 @@ import { isSettingsDirty } from "./update-settings" // The JSON tab: the page as one document, and an admin's edit of it applied in one write. -/** The saved page as `GET …/config` returned it, with every component and its revision. */ +/** The saved page as `GET …/config` returned it, with every component and its version. */ export const currentConfigOf = (model: Model): Option.Option => AsyncData.getData(model.config.data) @@ -76,7 +76,7 @@ export const cancelJson = (model: Model): Return => export const applyJson = (model: Model): Return => { const pageId = model.pageId if (pageId === null || !canApply(model)) return { model } - const ifRevision = Option.getOrUndefined(currentConfigOf(model))?.revision + const ifVersion = Option.getOrUndefined(currentConfigOf(model))?.version return Option.match(draftOf(model), { onNone: () => ({ model }), onSome: (draft) => @@ -84,7 +84,7 @@ export const applyJson = (model: Model): Return => { onFailure: () => ({ model }), onSuccess: ({ config }) => ({ model: { ...model, isApplyingJson: true, jsonFailure: null }, - commands: [ApplyConfig({ pageId, config, ifRevision })], + commands: [ApplyConfig({ pageId, config, ifVersion })], }), }), }) diff --git a/apps/web/src/page/alerts/status-pages/test-pages.ts b/apps/web/src/page/alerts/status-pages/test-pages.ts index ab005c7f61..8d5820d0de 100644 --- a/apps/web/src/page/alerts/status-pages/test-pages.ts +++ b/apps/web/src/page/alerts/status-pages/test-pages.ts @@ -5,11 +5,12 @@ import { StatusPageIncidentUpdateId, } from "@maple/domain" import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page" -import type { - StatusPageConfig, - V2StatusPage, - V2StatusPageComponent, - V2StatusPageIncident, +import { + type StatusPageConfig, + statusPageConfigVersion, + type V2StatusPage, + type V2StatusPageComponent, + type V2StatusPageIncident, } from "@maple/domain/http/v2" import { Schema } from "effect" import { NOW } from "../shared/test-rows" @@ -97,23 +98,25 @@ export const incident = (n: number, fields: Partial = {}): export const statusPageConfig = ( page: V2StatusPage, components: ReadonlyArray, -): StatusPageConfig => ({ - revision: page.revision, - name: page.name, - slug: page.slug, - description: page.description, - website_url: page.website_url, - logo_url: page.logo_url, - show_powered_by: page.show_powered_by, - auto_incidents: page.auto_incidents, - theme: page.theme, - components: components.map((row) => ({ - id: row.id, - name: row.name, - description: row.description, - group: row.group, - monitor_id: row.monitor_id, - manual_status: row.manual_status, - show_uptime: row.show_uptime, - })), -}) +): StatusPageConfig => { + const content: StatusPageConfig = { + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.website_url, + logo_url: page.logo_url, + show_powered_by: page.show_powered_by, + auto_incidents: page.auto_incidents, + theme: page.theme, + components: components.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + group: row.group, + monitor_id: row.monitor_id, + manual_status: row.manual_status, + show_uptime: row.show_uptime, + })), + } + return { version: statusPageConfigVersion(content), ...content } +} diff --git a/docs/api-v2.md b/docs/api-v2.md index ff6572559b..f8ea4b97a1 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -197,7 +197,7 @@ The long tail of ~40 query-engine endpoints (facets, infra hosts/pods/nodes/work ### Status page config document -`GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400, as does a key the document does not define (so a typo never resets a field) or more than 200 components. The read carries the page `revision`; the apply ignores that key, but `PUT …/config?if_revision=` answers 409 `status_page_conflict` and writes nothing when the page has moved past `n`. The apply locks the page row before reading its components, and component writes take the same lock, so they serialize. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document, and both send the revision they read. +`GET /v2/status_pages/{id}/config` returns a page's settings, theme and ordered components as one `StatusPageConfig` document (no incidents, break-glass token or publish state). `PUT` the same document back to replace the page in one transaction with one revision bump: a component with an `id` is updated, one without is created, one left out is deleted, and list order is display order. Omitted optional fields take their defaults, and any invalid part (an `id` from another page, a duplicate, a foreign monitor, an unreadable theme) rejects the whole document with a 400, as does a key the document does not define (so a typo never resets a field) or more than 200 components. The read carries a `version`, a hash of the normalized document; the apply ignores that key, but `PUT …/config?if_version=` answers 409 `status_page_conflict` and writes nothing when the config's content no longer hashes to `v`. It is a content tag, not the page `revision`, so monitor status flips, reconciles and publishes (which bump `revision`) never refuse an apply; only an edit to the document does. The apply locks the page row before reading its components, and component writes take the same lock, so they serialize. The MCP tools `get_status_page_config` and `apply_status_page_config` and the editor's JSON tab use the same document, and both send the version they read. ### Telemetry reads diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 2ef5a794ce..0fafb4d880 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -566,8 +566,9 @@ describe("StatusPagesService", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const { page, api, web, docs, config } = yield* seeded(testDb) + assert.match(config.version ?? "", /^[0-9a-z]+$/u) assert.deepStrictEqual(config, { - revision: yield* revision(testDb, page.id), + version: config.version, name: page.name, slug: "config", description: null, @@ -659,7 +660,7 @@ describe("StatusPagesService", () => { const { service, page, config } = yield* seeded(testDb) const before = yield* revision(testDb, page.id) const applied = yield* service.applyConfig(ORG, page.id, config, USER) - assert.deepStrictEqual(applied.config, { ...config, revision: before + 1 }) + assert.deepStrictEqual(applied.config, config) assert.deepStrictEqual(applied.changes, NO_CHANGES) assert.strictEqual(yield* revision(testDb, page.id), before + 1) }).pipe(Effect.provide(makeLayer(testDb))) @@ -774,25 +775,67 @@ describe("StatusPagesService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) - it.effect("with if_revision, a page that moved on is a conflict and nothing is written", () => { + it.effect("with if_version, an edited config is a conflict and nothing is written", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { - const { service, page, config } = yield* seeded(testDb) - const stale = config.revision ?? 0 + const { service, page, config, web } = yield* seeded(testDb) yield* service.createComponent(ORG, page.id, { name: "Added meanwhile" }) const moved = yield* service.getConfig(ORG, page.id) + assert.notStrictEqual(moved.version, config.version) const conflict = yield* Effect.flip( - service.applyConfig(ORG, page.id, config, USER, { ifRevision: stale }), + service.applyConfig(ORG, page.id, config, USER, { ifVersion: config.version }), ) assert.strictEqual(conflict._tag, "@maple/http/errors/StatusPageConflictError") - assert.include(conflict.message, `revision ${stale}`) + assert.include(conflict.message, "changed since it was read") assert.deepStrictEqual(yield* service.getConfig(ORG, page.id), moved) - const applied = yield* service.applyConfig(ORG, page.id, moved, USER, { - ifRevision: moved.revision, + const applied = yield* service.applyConfig(ORG, page.id, { ...moved, name: "Fresh" }, USER, { + ifVersion: moved.version, }) - assert.deepStrictEqual(applied.changes, NO_CHANGES) - assert.strictEqual(applied.config.revision, (moved.revision ?? 0) + 1) + assert.strictEqual(applied.config.name, "Fresh") + assert.notStrictEqual(applied.config.version, moved.version) + assert.isTrue(applied.config.components.some((c) => c.id === web.id)) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("a status flip between read and apply is no conflict; a rename by someone else is", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { service, page, config, web } = yield* seeded(testDb) + const before = yield* revision(testDb, page.id) + // What a monitor flip does to the page (`bumpPagesForMonitors`), and a reconcile's bump. + yield* Effect.promise(() => + executeSql( + testDb, + `UPDATE status_pages SET revision = revision + 1 WHERE id IN ( + SELECT page_id FROM status_page_components WHERE monitor_id = $1)`, + [MONITOR], + ), + ) + yield* Effect.promise(() => + executeSql(testDb, `UPDATE status_pages SET revision = revision + 1 WHERE id = $1`, [ + page.id, + ]), + ) + assert.strictEqual(yield* revision(testDb, page.id), before + 2) + const applied = yield* service.applyConfig( + ORG, + page.id, + { ...config, description: "Edited during an outage." }, + USER, + { ifVersion: config.version }, + ) + assert.deepStrictEqual(applied.changes.fields, ["description"]) + + const read = yield* service.getConfig(ORG, page.id) + yield* service.updateComponent(ORG, web.id, { name: "Web app" }) + const conflict = yield* Effect.flip( + service.applyConfig(ORG, page.id, { ...read, name: "Mine" }, USER, { + ifVersion: read.version, + }), + ) + assert.strictEqual(conflict._tag, "@maple/http/errors/StatusPageConflictError") + assert.strictEqual((yield* service.get(ORG, page.id)).name, read.name) }).pipe(Effect.provide(makeLayer(testDb))) }) diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index 5b5cb40fb3..4b63e2e9ff 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -31,6 +31,7 @@ import { MAX_STATUS_PAGE_COMPONENTS, normalizeStatusPageConfig, StatusPageComponentPublicId, + statusPageConfigVersion, type StatusPageConfig, type StatusPageConfigChangeSet, UptimeMonitorPublicId, @@ -234,14 +235,14 @@ export interface StatusPagesServiceApi { readonly getConfig: (orgId: OrgId, pageId: StatusPageId) => Effect.Effect /** * Replaces the page with `config` in one transaction: all of it applies, or none of it. With - * `ifRevision`, a page past that revision fails with a conflict and nothing is written. + * `ifVersion`, a config edited since that version fails with a conflict and nothing is written. */ readonly applyConfig: ( orgId: OrgId, pageId: StatusPageId, config: StatusPageConfig, actor: UserId, - options?: { readonly ifRevision?: number | undefined }, + options?: { readonly ifVersion?: string | undefined }, ) => Effect.Effect< AppliedStatusPageConfig, PageWriteError | StatusPageSlugTakenError | StatusPageConflictError @@ -624,11 +625,10 @@ const ensureMonitorsInOrg = Effect.fnUntraced(function* ( if (missing !== undefined) return yield* invalid(`Unknown uptime monitor ${encodeMonitorId(missing)}`) }) -const toConfig = ( +const contentOf = ( page: StatusPageRow, components: ReadonlyArray, ): StatusPageConfig => ({ - revision: page.revision, name: page.name, slug: page.slug, description: page.description, @@ -648,6 +648,15 @@ const toConfig = ( })), }) +/** The page's config as a read returns it, tagged with its content version. */ +const toConfig = ( + page: StatusPageRow, + components: ReadonlyArray, +): StatusPageConfig => { + const content = contentOf(page, components) + return { version: statusPageConfigVersion(content), ...content } +} + /** One config component, validated and with its defaults, before any id is checked. */ const validateConfigComponent = Effect.fnUntraced(function* ( component: StatusPageConfig["components"][number], @@ -1382,7 +1391,7 @@ export class StatusPagesService extends Context.Service db.transaction( Effect.gen(function* () { // Locked before the components are read, so the change set below is the write's. const current = yield* lockPage(db, orgId, pageId) - if (ifRevision !== undefined && ifRevision !== current.revision) + const existing = yield* selectComponents(db, orgId, pageId) + const previous = toConfig(current, existing) + const version = statusPageConfigVersion(previous) + // The content's version, not the page revision: status flips and publishes leave it. + if (ifVersion !== undefined && ifVersion !== version) return yield* new StatusPageConflictError({ pageId, - revision: current.revision, - message: `The page changed since revision ${ifRevision} (it is now at ${current.revision}). Read the config again and redo the edit on it.`, + version, + message: + "The page's config changed since it was read. Read the config again and redo the edit on it.", }) - const existing = yield* selectComponents(db, orgId, pageId) const byId = new Map(existing.map((row) => [row.id, row])) const foreign = listedIds.find((id) => !byId.has(id)) if (foreign !== undefined) @@ -1452,7 +1465,6 @@ export class StatusPagesService extends Context.Service()( "@maple/http/errors/StatusPageConflictError", { pageId: StatusPageId, - revision: Schema.Number, + /** The config's current version, for a caller that wants to compare. */ + version: Schema.String, message: Schema.String, }, { status: 409, code: "status_page_conflict", title: "Status page changed", - param: "if_revision", + param: "if_version", retry: "never", recovery: "fix_request", exposure: "public_message", diff --git a/packages/domain/src/http/v2/status-page-config.test.ts b/packages/domain/src/http/v2/status-page-config.test.ts index 7a9f94904f..f64decc19a 100644 --- a/packages/domain/src/http/v2/status-page-config.test.ts +++ b/packages/domain/src/http/v2/status-page-config.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { Schema } from "effect" import { StatusPageComponentId } from "../../primitives" import { DEFAULT_STATUS_PAGE_THEME } from "../../status-page" -import { diffStatusPageConfig, StatusPageConfig } from "./status-pages" +import { diffStatusPageConfig, StatusPageConfig, statusPageConfigVersion } from "./status-pages" const API = StatusPageComponentId.make("11111111-1111-4111-8111-111111111111") const WEB = StatusPageComponentId.make("22222222-2222-4222-8222-222222222222") @@ -141,3 +141,70 @@ describe("diffStatusPageConfig", () => { expect(decode(document(201))._tag).toBe("Failure") }) }) + +describe("statusPageConfigVersion", () => { + const version = statusPageConfigVersion(current) + + it("is the same short tag for equal documents, whatever their version key says", () => { + expect(version).toMatch(/^[0-9a-z]{1,11}$/u) + expect(statusPageConfigVersion(structuredClone(current))).toBe(version) + expect(statusPageConfigVersion({ ...current, version: "anything" })).toBe(version) + }) + + it("changes when a component changes, is added or moves", () => { + const renamed = current.components.map((component) => + component.id === API ? { ...component, name: "API v2" } : component, + ) + expect(statusPageConfigVersion({ ...current, components: renamed })).not.toBe(version) + expect( + statusPageConfigVersion({ ...current, components: [...current.components, { name: "New" }] }), + ).not.toBe(version) + expect( + statusPageConfigVersion({ ...current, components: [...current.components].reverse() }), + ).not.toBe(version) + }) + + it("ignores key order", () => { + const reordered: StatusPageConfig = { + components: current.components.map((component) => ({ + show_uptime: component.show_uptime, + manual_status: component.manual_status, + monitor_id: component.monitor_id, + group: component.group, + description: component.description, + name: component.name, + id: component.id, + })), + theme: { + font: DEFAULT_STATUS_PAGE_THEME.font, + radius: DEFAULT_STATUS_PAGE_THEME.radius, + appearance: DEFAULT_STATUS_PAGE_THEME.appearance, + preset: DEFAULT_STATUS_PAGE_THEME.preset, + }, + auto_incidents: current.auto_incidents, + show_powered_by: current.show_powered_by, + logo_url: current.logo_url, + website_url: current.website_url, + description: current.description, + slug: current.slug, + name: current.name, + } + expect(statusPageConfigVersion(reordered)).toBe(version) + }) + + it("ignores differences an apply would normalize away: padding, blanks, hex case, defaults", () => { + const accented = { ...current, theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: "#1a2b3c" } } + expect( + statusPageConfigVersion({ + ...accented, + name: " Acme ", + description: " ", + theme: { ...DEFAULT_STATUS_PAGE_THEME, accent: " #1A2B3C" }, + components: current.components.map((component) => ({ + id: component.id, + name: ` ${component.name}`, + })), + }), + ).toBe(statusPageConfigVersion(accented)) + }) +}) diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 381867c393..2dad781c38 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -474,11 +474,11 @@ export type StatusPageConfigComponent = Schema.Schema.Type { } } +/** JSON with object keys sorted at every depth; arrays keep their order. */ +const canonicalJson = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]` + if (typeof value === "object" && value !== null) + return `{${Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}` + return JSON.stringify(value) ?? "null" +} + +/** cyrb53 over UTF-16 code units: 53 bits, the same in browsers, workerd and Node. */ +const cyrb53 = (text: string): number => { + const seed: readonly [number, number] = [0xdeadbeef, 0x41c6ce57] + const [a, b] = Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)).reduce( + ([h1, h2], code): readonly [number, number] => [ + Math.imul(h1 ^ code, 2654435761), + Math.imul(h2 ^ code, 1597334677), + ], + seed, + ) + const a1 = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909) + const b1 = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(a1 ^ (a1 >>> 13), 3266489909) + return 4294967296 * (2097151 & b1) + (a1 >>> 0) +} + +/** + * The version of a config's content: a hash of its normalized, canonical form, so only an edit + * to the document changes it. An optimistic-concurrency tag, not a security boundary. + */ +export const statusPageConfigVersion = (config: StatusPageConfig): string => + cyrb53(canonicalJson(normalizeStatusPageConfig(config))).toString(36) + const PAGE_CONFIG_FIELDS = [ "name", "slug", @@ -821,16 +855,16 @@ const StatusPageIncidentList = ListOf(V2StatusPageIncident).annotate({ }) const ApplyConfigQuery = Schema.Struct({ - if_revision: Schema.optional( - Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)).annotate({ + if_version: Schema.optional( + Schema.String.annotate({ description: - "The `revision` the document was read at. When the page has moved past it, the apply answers 409 `status_page_conflict` and changes nothing.", + "The `version` the document was read with. When the page's config has changed since, the apply answers 409 `status_page_conflict` and changes nothing.", }), ), }).annotate({ identifier: "StatusPageConfigApplyQuery", title: "Status page config apply query", - description: "An optional precondition on the page revision.", + description: "An optional precondition on the config version.", }) const pageParams = { id: StatusPagePublicId } @@ -955,7 +989,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") identifier: "getStatusPageConfig", summary: "Retrieve a status page config", description: - "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes, with the `revision` it was read at. Requires the `status_pages:read` scope.", + "Returns the page's settings, theme and components as one document, the body `PUT …/config` takes, with its `version`. Requires the `status_pages:read` scope.", }), ), ) @@ -981,7 +1015,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") OpenApi.annotations({ identifier: "applyStatusPageConfig", summary: "Apply a status page config", - description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part, or a key the document does not define, rejects the whole document. With \`if_revision\`, a page that changed since that revision answers 409 and nothing is written. Returns the applied config. ${adminNote}`, + description: `Replaces the page with the document in one write: components listed with an \`id\` are updated, without one created, and left out deleted; list order is display order. Any invalid part, or a key the document does not define, rejects the whole document. With \`if_version\`, a config that changed since that version was read answers 409 and nothing is written; status flips and publishes do not change it. Returns the applied config. ${adminNote}`, }), ), ) From d7958b7ffb99576aa30a57c8c94599b63072f11c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 16:14:28 +0100 Subject: [PATCH 09/17] fix(web): JSON draft scanner walks members in a loop and caps nesting depth --- .../status-pages/editor/config-json.test.ts | 21 +++++++++++ .../alerts/status-pages/editor/config-json.ts | 36 ++++++++++++------- 2 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/page/alerts/status-pages/editor/config-json.test.ts diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts new file mode 100644 index 0000000000..76da608cc0 --- /dev/null +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.test.ts @@ -0,0 +1,21 @@ +import { Result } from "effect" +import { describe, expect, it } from "vitest" +import { statusPage, statusPageConfig } from "../test-pages" +import { parseDraft, problemText } from "./config-json" + +const problemOf = (text: string) => + Result.match(parseDraft(text, statusPageConfig(statusPage(), [])), { + onFailure: (problem) => problemText(problem), + onSuccess: () => "ok", + }) + +describe("parseDraft on hostile input", () => { + it("reports deep nesting as a problem instead of overflowing the stack", () => { + expect(problemOf("[".repeat(20_000))).toContain("Nested too deeply") + }) + + it("walks a very long array without overflowing the stack", () => { + const longArray = `[${Array.from({ length: 20_000 }, () => "1").join(",")}` + expect(problemOf(longArray)).toContain("column 40001") + }) +}) diff --git a/apps/web/src/page/alerts/status-pages/editor/config-json.ts b/apps/web/src/page/alerts/status-pages/editor/config-json.ts index 599537a043..496dfdf747 100644 --- a/apps/web/src/page/alerts/status-pages/editor/config-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/config-json.ts @@ -56,40 +56,50 @@ const scanString = (text: string, at: number): Scan => { return end === null ? fail(at, "Unterminated or invalid string") : { ok: true, at: end } } +/** Deeper than any status page config; past it the scan reports a problem instead of overflowing the stack. */ +const MAX_DEPTH = 64 + +/** Members one after another in a loop, so a long array never deepens the stack. */ const scanMembers = ( text: string, - at: number, + from: number, close: "}" | "]", member: (text: string, at: number) => Scan, ): Scan => { - const scanned = member(text, skip(text, at)) - if (!scanned.ok) return scanned - const next = skip(text, scanned.at) - if (text[next] === ",") return scanMembers(text, next + 1, close, member) - if (text[next] === close) return { ok: true, at: next + 1 } - return fail(next, `Expected ',' or '${close}'`) + let at = from + while (true) { + const scanned = member(text, skip(text, at)) + if (!scanned.ok) return scanned + const next = skip(text, scanned.at) + if (text[next] === close) return { ok: true, at: next + 1 } + if (text[next] !== ",") return fail(next, `Expected ',' or '${close}'`) + at = next + 1 + } } -const scanProperty = (text: string, at: number): Scan => { +const scanProperty = (text: string, at: number, depth: number): Scan => { if (text[at] !== '"') return fail(at, "Expected a double-quoted property name") const key = scanString(text, at) if (!key.ok) return key const colon = skip(text, key.at) if (text[colon] !== ":") return fail(colon, "Expected ':' after the property name") - return scanValue(text, skip(text, colon + 1)) + return scanValue(text, skip(text, colon + 1), depth) } -const scanValue = (text: string, at: number): Scan => { +const scanValue = (text: string, at: number, depth: number): Scan => { const char = text[at] + if ((char === "{" || char === "[") && depth >= MAX_DEPTH) return fail(at, "Nested too deeply") if (char === "{") { const inner = skip(text, at + 1) - return text[inner] === "}" ? { ok: true, at: inner + 1 } : scanMembers(text, inner, "}", scanProperty) + return text[inner] === "}" + ? { ok: true, at: inner + 1 } + : scanMembers(text, inner, "}", (source, from) => scanProperty(source, from, depth + 1)) } if (char === "[") { const inner = skip(text, at + 1) return text[inner] === "]" ? { ok: true, at: inner + 1 } - : scanMembers(text, inner, "]", (source, from) => scanValue(source, from)) + : scanMembers(text, inner, "]", (source, from) => scanValue(source, from, depth + 1)) } if (char === '"') return scanString(text, at) const end = token(NUMBER, text, at) ?? token(LITERAL, text, at) @@ -103,7 +113,7 @@ const locate = (text: string, at: number) => { } const syntaxProblem = (text: string): DraftProblem => { - const scanned = scanValue(text, skip(text, 0)) + const scanned = scanValue(text, skip(text, 0), 0) const end = scanned.ok ? skip(text, scanned.at) : scanned.at const message = scanned.ok ? "Unexpected text after the document" : scanned.message return { _tag: "Syntax", message, ...locate(text, Math.min(end, text.length)) } From 133969a224f2d3848cc717353444c786045f031e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 16:17:49 +0100 Subject: [PATCH 10/17] fix(status-pages): a config apply that changes nothing neither republishes nor audits --- apps/ai/src/mcp/tools/status-pages.ts | 38 +++++++++++-------- .../src/routes/v2/status-pages.http.test.ts | 5 ++- apps/api/src/routes/v2/status-pages.http.ts | 2 + .../status-pages/StatusPagesService.test.ts | 4 +- .../status-pages/StatusPagesService.ts | 5 ++- packages/domain/src/http/v2/status-pages.ts | 5 +++ 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index a620ad5707..a3e823e633 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -13,7 +13,13 @@ import { type StatusPageIncidentRow, type StatusPageRow, } from "@maple/domain/mcp-outputs" -import { decodePublicId, encodePublicId, PublicIdPrefixes, StatusPageConfig } from "@maple/domain/http/v2" +import { + decodePublicId, + encodePublicId, + hasStatusPageConfigChanges, + PublicIdPrefixes, + StatusPageConfig, +} from "@maple/domain/http/v2" import type { StatusPageComponentRecord, StatusPageIncidentRecord, @@ -656,20 +662,22 @@ export function registerStatusPageTools(server: McpToolRegistrar) { }) .pipe(Effect.mapError(toMcpError(tool))) const audit = yield* AuditLogService - yield* audit.record({ - orgId: tenant.orgId, - ...auditAttribution(tenant, yield* CurrentAuditActor), - action: "status_page.config_applied", - resourceId: page.id, - metadata: { - name: config.name, - fields_changed: changes.fields.join(","), - components_created: changes.created.length, - components_updated: changes.updated.length, - components_deleted: changes.deleted.length, - components_reordered: changes.reordered, - }, - }) + // Nothing written, nothing to audit. + if (hasStatusPageConfigChanges(changes)) + yield* audit.record({ + orgId: tenant.orgId, + ...auditAttribution(tenant, yield* CurrentAuditActor), + action: "status_page.config_applied", + resourceId: page.id, + metadata: { + name: config.name, + fields_changed: changes.fields.join(","), + components_created: changes.created.length, + components_updated: changes.updated.length, + components_deleted: changes.deleted.length, + components_reordered: changes.reordered, + }, + }) const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool))) return { page: toPageRow(applied), config, changes } }), 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 88b0526232..415fdef8a9 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -475,7 +475,7 @@ describe("v2 status pages", () => { return { id: page.body.id as string, apiId: api.body.id as string, webId: web.body.id as string } } - it("round-trips the document with a single revision bump", () => + it("round-trips the document without a revision bump", () => withHarness(async (harness) => { const { secret: token } = await harness.bootstrapKey() const { id, apiId, webId } = await seedPage(harness, token, "acme-config") @@ -503,7 +503,8 @@ describe("v2 status pages", () => { expect(applied.status).toBe(200) expect(applied.body).toEqual(read.body) const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision - expect(after).toBe(before + 1) + // Nothing changed, so nothing republishes. + expect(after).toBe(before) })) it("applies created, updated, deleted and reordered components with the page fields", () => diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index eacbae30ba..8b6e83b765 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -18,6 +18,7 @@ import { type V2StatusPage, type V2StatusPageComponent, type V2StatusPageIncident, + hasStatusPageConfigChanges, } from "@maple/domain/http/v2" import type { OrgId, @@ -353,6 +354,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) const { components: _before, version: _beforeVersion, ...pageBefore } = previous const { components: _after, version: _afterVersion, ...pageAfter } = config + if (!hasStatusPageConfigChanges(changes)) return config yield* recordHttpAudit("status_page.config_applied", { resourceId: params.id, changes: diffAuditChanges(pageBefore, pageAfter), diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 0fafb4d880..60c7ebb6dc 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -654,7 +654,7 @@ describe("StatusPagesService", () => { }, ) - it.effect("re-applying the current document bumps the revision once and changes nothing", () => { + it.effect("re-applying the current document changes nothing and leaves the revision alone", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const { service, page, config } = yield* seeded(testDb) @@ -662,7 +662,7 @@ describe("StatusPagesService", () => { const applied = yield* service.applyConfig(ORG, page.id, config, USER) assert.deepStrictEqual(applied.config, config) assert.deepStrictEqual(applied.changes, NO_CHANGES) - assert.strictEqual(yield* revision(testDb, page.id), before + 1) + assert.strictEqual(yield* revision(testDb, page.id), before) }).pipe(Effect.provide(makeLayer(testDb))) }) diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index 4b63e2e9ff..e6caab3b66 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -28,6 +28,7 @@ import { } from "@maple/domain/http" import { diffStatusPageConfig, + hasStatusPageConfigChanges, MAX_STATUS_PAGE_COMPONENTS, normalizeStatusPageConfig, StatusPageComponentPublicId, @@ -1546,7 +1547,9 @@ export class StatusPagesService extends Context.Service + changes.fields.length + changes.created.length + changes.updated.length + changes.deleted.length > 0 || + changes.reordered + export interface StatusPageConfigChanges extends StatusPageConfigChangeSet { /** Ids listed that are not components of the page, or listed twice: the apply refuses both. */ readonly unknownIds: ReadonlyArray From 3452e3d52e1af7e1f4dbbaf06d45089f13b61ffe Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 16:22:59 +0100 Subject: [PATCH 11/17] fix(web): JSON apply sends the version the draft was opened from --- .../status-pages/editor/editor.scene.test.ts | 19 ++++++++++++++++++ .../page/alerts/status-pages/editor/model.ts | 2 ++ .../alerts/status-pages/editor/update-json.ts | 20 +++++++++++-------- .../page/alerts/status-pages/editor/update.ts | 1 + 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 32a970a897..615eb8296f 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -807,6 +807,25 @@ describe("json tab", () => { ]) }) + test("a newer config read while editing does not replace the version the draft was opened from", () => { + const base = { + ...statusPageConfig(statusPage(), [component(1), component(2), component(3)]), + version: "v7", + } + const draft = editing(JSON.stringify({ ...documentOf([1, 2]) }), { config: base }) + // Someone else's edit lands and the tab re-reads it, mid-edit. + const reread = pageUpdate( + draft, + Message.CompletedFetchConfig({ + key: draft.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(pageUpdate(reread, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifVersion: "v7" } }, + ]) + }) + test("a draft that differs only by spacing or colour case is no change", () => { const theme = { ...DEFAULT_STATUS_PAGE_THEME, appearance: "light" as const, accent: "#1a2b3c" } const page = statusPage({ description: "Live status.", theme }) diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts index 7f403a3001..e713e7af2b 100644 --- a/apps/web/src/page/alerts/status-pages/editor/model.ts +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -78,6 +78,8 @@ export const Model = Schema.Struct({ config: KeyedSchema(StatusPageConfig, LoadFailure), /** The edited document; `null` while the tab shows the page as saved. */ jsonDraft: Schema.NullOr(Schema.String), + /** The saved document the draft was opened from: its diff base and the version an apply sends. */ + jsonBase: Schema.NullOr(StatusPageConfig), isApplyingJson: Schema.Boolean, jsonFailure: Schema.NullOr(LoadFailure), jsonCopy: CopyButton.Model, diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index 7c2e75a222..eab87e71b8 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -27,11 +27,11 @@ import { isSettingsDirty } from "./update-settings" export const currentConfigOf = (model: Model): Option.Option => AsyncData.getData(model.config.data) -/** The draft parsed against the saved page; none while not editing. */ +/** The draft parsed against the document it was opened from; none while not editing. */ export const draftOf = (model: Model): Option.Option => - model.jsonDraft === null + model.jsonDraft === null || model.jsonBase === null ? Option.none() - : Option.map(currentConfigOf(model), (current) => parseDraft(model.jsonDraft ?? "", current)) + : Option.some(parseDraft(model.jsonDraft, model.jsonBase)) const hasChanges = (draft: DraftResult): boolean => Result.match(draft, { @@ -52,16 +52,16 @@ export const canApply = (model: Model): boolean => !isSettingsDirty(model) && Option.match(draftOf(model), { onNone: () => false, onSome: hasChanges }) -const withCurrent = (model: Model, write: (text: string) => Model): Return => +const withCurrent = (model: Model, write: (text: string, base: StatusPageConfig) => Model): Return => Option.match(currentConfigOf(model), { onNone: () => ({ model }), - onSome: (current) => ({ model: write(formatConfig(current)) }), + onSome: (current) => ({ model: write(formatConfig(current), current) }), }) /** Opens the editor on the saved document; Reset runs it again over the draft. */ export const editJson = (model: Model): Return => model.isAdmin - ? withCurrent(model, (jsonDraft) => ({ ...model, jsonDraft, jsonFailure: null })) + ? withCurrent(model, (jsonDraft, jsonBase) => ({ ...model, jsonDraft, jsonBase, jsonFailure: null })) : { model } /** Ignored while an apply runs: its result replaces the draft, and would drop the keystrokes. */ @@ -71,12 +71,15 @@ export const changeJson = (model: Model, value: string): Return => : { model: { ...model, jsonDraft: value, jsonFailure: null } } export const cancelJson = (model: Model): Return => - model.isApplyingJson ? { model } : { model: { ...model, jsonDraft: null, jsonFailure: null } } + model.isApplyingJson + ? { model } + : { model: { ...model, jsonDraft: null, jsonBase: null, jsonFailure: null } } export const applyJson = (model: Model): Return => { const pageId = model.pageId if (pageId === null || !canApply(model)) return { model } - const ifVersion = Option.getOrUndefined(currentConfigOf(model))?.version + // The version the draft was opened from, not the newest read: a re-read must not hide someone else's edit. + const ifVersion = model.jsonBase?.version return Option.match(draftOf(model), { onNone: () => ({ model }), onSome: (draft) => @@ -132,6 +135,7 @@ export const appliedJson = ( ...model, isApplyingJson: false, jsonDraft: null, + jsonBase: null, settingsEdits: settingsAfterApply(model, applied), }, readPage(context, "Supersede"), diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 61cc0b1f8f..a985a8d0bb 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -115,6 +115,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { deletePageDialog: Dialog.init("status-page-delete", "alert-dialog"), isDeletingPage: false, jsonDraft: null, + jsonBase: null, isApplyingJson: false, jsonFailure: null, jsonCopy: CopyButton.init(), From f6c2b687a8d75006c2ceac9d7f35e21ff2b78e68 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 20:38:14 +0100 Subject: [PATCH 12/17] fix(web): after a JSON apply conflict the draft rebases onto the re-read config --- .../status-pages/editor/editor.scene.test.ts | 28 +++++++++++++++++++ .../page/alerts/status-pages/editor/model.ts | 2 ++ .../alerts/status-pages/editor/update-json.ts | 19 +++++++++++-- .../page/alerts/status-pages/editor/update.ts | 13 +++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 615eb8296f..a97642c36d 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -910,6 +910,34 @@ describe("json tab", () => { scene({ update: pageUpdate, view }, given(refused.model), expectView(text(reason)).toExist()) }) + test("after a conflict the re-read becomes the draft's base, so the next Apply can go through", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const draftText = JSON.stringify(documentOf([1])) + const applying = pageUpdate(editing(draftText, { config: base }), Message.ClickedApplyJson()).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + const reread = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: refused.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(reread.jsonDraft).toBe(draftText) + expect(pageUpdate(reread, Message.ClickedApplyJson()).commands).toMatchObject([ + { name: "ApplyStatusPageConfig", args: { ifVersion: "v8" } }, + ]) + }) + test("unsaved Settings edits block Apply until they are saved or discarded", () => { const draft = JSON.stringify(documentOf([1])) const dirty = pageUpdate( diff --git a/apps/web/src/page/alerts/status-pages/editor/model.ts b/apps/web/src/page/alerts/status-pages/editor/model.ts index e713e7af2b..6a52917a86 100644 --- a/apps/web/src/page/alerts/status-pages/editor/model.ts +++ b/apps/web/src/page/alerts/status-pages/editor/model.ts @@ -80,6 +80,8 @@ export const Model = Schema.Struct({ jsonDraft: Schema.NullOr(Schema.String), /** The saved document the draft was opened from: its diff base and the version an apply sends. */ jsonBase: Schema.NullOr(StatusPageConfig), + /** After a conflict: the next config read becomes the draft's base, keeping the typed draft. */ + isRebasingJson: Schema.Boolean, isApplyingJson: Schema.Boolean, jsonFailure: Schema.NullOr(LoadFailure), jsonCopy: CopyButton.Model, diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index eab87e71b8..751de78f83 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -145,13 +145,28 @@ export const appliedJson = ( ), outMessage: toast("success", "Config applied", "It goes out with the next publish."), }), - // A conflict means the page moved on: re-read it so Reset starts from the current one. + // A conflict means the page moved on: re-read it and rebase the draft onto it, keeping the text. onFailure: (jsonFailure): Return => jsonFailure.code === "status_page_conflict" - ? chain({ ...model, isApplyingJson: false, jsonFailure }, readConfig(context, "Supersede")) + ? chain( + { ...model, isApplyingJson: false, isRebasingJson: true, jsonFailure }, + readConfig(context, "Supersede"), + ) : { model: { ...model, isApplyingJson: false, jsonFailure } }, }) +/** + * Once the re-read after a conflict lands, the draft is checked against the current page: the + * summary shows what the next Apply would overwrite, and that Apply carries the current version. + */ +export const rebaseJsonDraft = (model: Model): Model => + model.isRebasingJson && model.jsonDraft !== null + ? Option.match(currentConfigOf(model), { + onNone: () => model, + onSome: (current) => ({ ...model, jsonBase: current, isRebasingJson: false }), + }) + : model + export const updateJsonCopy = (model: Model, message: CopyButton.Message): Return => { const step = CopyButton.update(model.jsonCopy, message) return { diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index a985a8d0bb..226e29dfe0 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -64,7 +64,15 @@ import { changeTheme, updateTokenCopy, } from "./update-settings" -import { appliedJson, applyJson, cancelJson, changeJson, editJson, updateJsonCopy } from "./update-json" +import { + appliedJson, + applyJson, + cancelJson, + changeJson, + editJson, + rebaseJsonDraft, + updateJsonCopy, +} from "./update-json" type EditorRoute = Extract @@ -116,6 +124,7 @@ export const init = (route: EditorRoute, context: PageContext): Return => { isDeletingPage: false, jsonDraft: null, jsonBase: null, + isRebasingJson: false, isApplyingJson: false, jsonFailure: null, jsonCopy: CopyButton.init(), @@ -167,7 +176,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [ReadEditorClock()], }), CompletedFetchConfig: ({ key, result }) => ({ - model: { ...model, config: Keyed.accept(model.config, key, result) }, + model: rebaseJsonDraft({ ...model, config: Keyed.accept(model.config, key, result) }), }), CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, From 7e359f8e1d684f41835305adf108a186ed9da02e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 20:57:48 +0100 Subject: [PATCH 13/17] fix(web): a cancelled or new JSON draft ends any pending rebase --- .../status-pages/editor/editor.scene.test.ts | 32 +++++++++++++++++++ .../alerts/status-pages/editor/update-json.ts | 11 +++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index a97642c36d..a91b012078 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -938,6 +938,38 @@ describe("json tab", () => { ]) }) + test("cancelling after a conflict ends the rebase, so a later draft keeps its own base", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + // Cancel before the re-read lands, then start a fresh draft on v7. + const reopened = [Message.ClickedCancelJson(), Message.ClickedEditJson()].reduce( + (model, message) => pageUpdate(model, message).model, + refused, + ) + const reread = pageUpdate( + reopened, + Message.CompletedFetchConfig({ + key: reopened.config.key ?? "", + result: Result.succeed({ ...base, version: "v8" }), + }), + ).model + expect(reread.jsonBase?.version).toBe("v7") + }) + test("unsaved Settings edits block Apply until they are saved or discarded", () => { const draft = JSON.stringify(documentOf([1])) const dirty = pageUpdate( diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index 751de78f83..dee435bfb7 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -61,7 +61,13 @@ const withCurrent = (model: Model, write: (text: string, base: StatusPageConfig) /** Opens the editor on the saved document; Reset runs it again over the draft. */ export const editJson = (model: Model): Return => model.isAdmin - ? withCurrent(model, (jsonDraft, jsonBase) => ({ ...model, jsonDraft, jsonBase, jsonFailure: null })) + ? withCurrent(model, (jsonDraft, jsonBase) => ({ + ...model, + jsonDraft, + jsonBase, + isRebasingJson: false, + jsonFailure: null, + })) : { model } /** Ignored while an apply runs: its result replaces the draft, and would drop the keystrokes. */ @@ -73,7 +79,7 @@ export const changeJson = (model: Model, value: string): Return => export const cancelJson = (model: Model): Return => model.isApplyingJson ? { model } - : { model: { ...model, jsonDraft: null, jsonBase: null, jsonFailure: null } } + : { model: { ...model, jsonDraft: null, jsonBase: null, isRebasingJson: false, jsonFailure: null } } export const applyJson = (model: Model): Return => { const pageId = model.pageId @@ -136,6 +142,7 @@ export const appliedJson = ( isApplyingJson: false, jsonDraft: null, jsonBase: null, + isRebasingJson: false, settingsEdits: settingsAfterApply(model, applied), }, readPage(context, "Supersede"), From 7b5673fc46da2762648771b45e31dcaa120e4fb2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 21:07:28 +0100 Subject: [PATCH 14/17] fix(web): only an accepted config read rebases the JSON draft --- .../status-pages/editor/editor.scene.test.ts | 30 +++++++++++++++++++ .../page/alerts/status-pages/editor/update.ts | 8 +++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index a91b012078..720eb981c9 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -970,6 +970,36 @@ describe("json tab", () => { expect(reread.jsonBase?.version).toBe("v7") }) + test("a config read superseded by the conflict re-read does not rebase the draft", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const staleKey = applying.config.key ?? "" + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + // The older read lands after the conflict started a new one: it must not become the base. + const stale = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: staleKey, + result: Result.succeed({ ...base, version: "v6" }), + }), + ).model + expect(stale.isRebasingJson).toBe(true) + expect(stale.jsonBase?.version).toBe("v7") + }) + test("unsaved Settings edits block Apply until they are saved or discarded", () => { const draft = JSON.stringify(documentOf([1])) const dirty = pageUpdate( diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 226e29dfe0..747cecad2b 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -175,9 +175,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, incidents: Keyed.accept(model.incidents, key, result) }, commands: [ReadEditorClock()], }), - CompletedFetchConfig: ({ key, result }) => ({ - model: rebaseJsonDraft({ ...model, config: Keyed.accept(model.config, key, result) }), - }), + CompletedFetchConfig: ({ key, result }) => { + const next = { ...model, config: Keyed.accept(model.config, key, result) } + // Only a read the slot kept may rebase the draft; a superseded one carries older data. + return { model: key === model.config.key ? rebaseJsonDraft(next) : next } + }, CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, }), From 04c481f7b5d62577ead376b45220a4b313358012 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 29 Oct 2026 21:26:07 +0100 Subject: [PATCH 15/17] fix(web): a failed config re-read keeps the JSON rebase pending --- .../status-pages/editor/editor.scene.test.ts | 28 +++++++++++++++++++ .../page/alerts/status-pages/editor/update.ts | 9 ++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 720eb981c9..0576e8b701 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -1000,6 +1000,34 @@ describe("json tab", () => { expect(stale.jsonBase?.version).toBe("v7") }) + test("a failed re-read after a conflict leaves the rebase pending for the next good read", () => { + const base = { ...statusPageConfig(statusPage(), [component(1), component(2)]), version: "v7" } + const applying = pageUpdate( + editing(JSON.stringify(documentOf([1])), { config: base }), + Message.ClickedApplyJson(), + ).model + const refused = pageUpdate( + applying, + Message.CompletedApplyJson({ + result: Result.fail({ + ...toLoadFailure(new Error("changed")), + code: "status_page_conflict", + message: "changed", + retryable: false, + }), + }), + ).model + const failed = pageUpdate( + refused, + Message.CompletedFetchConfig({ + key: refused.config.key ?? "", + result: Result.fail(toLoadFailure(new Error("unreachable"))), + }), + ).model + expect(failed.isRebasingJson).toBe(true) + expect(failed.jsonBase?.version).toBe("v7") + }) + test("unsaved Settings edits block Apply until they are saved or discarded", () => { const draft = JSON.stringify(documentOf([1])) const dirty = pageUpdate( diff --git a/apps/web/src/page/alerts/status-pages/editor/update.ts b/apps/web/src/page/alerts/status-pages/editor/update.ts index 747cecad2b..ee549aa90c 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update.ts @@ -1,4 +1,4 @@ -import { Option } from "effect" +import { Option, Result } from "effect" import * as Keyed from "../../../../data/keyed" import type { AppRoute } from "../../../../route" import * as CopyButton from "../../../../ui/copy-button" @@ -177,8 +177,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), CompletedFetchConfig: ({ key, result }) => { const next = { ...model, config: Keyed.accept(model.config, key, result) } - // Only a read the slot kept may rebase the draft; a superseded one carries older data. - return { model: key === model.config.key ? rebaseJsonDraft(next) : next } + // Only a successful read the slot kept may rebase the draft; a superseded or failed one + // carries older data, and the rebase waits for the next good read. + return { + model: key === model.config.key && Result.isSuccess(result) ? rebaseJsonDraft(next) : next, + } }, CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, From 34baa7999f027953b195776146efbb3facd5a608 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 20:36:41 +0100 Subject: [PATCH 16/17] fix(web): a rebased JSON draft clears the conflict banner; Apply uses the shared change check --- .../status-pages/editor/editor.scene.test.ts | 1 + .../alerts/status-pages/editor/update-json.ts | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts index 0576e8b701..a028cdeb73 100644 --- a/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts @@ -933,6 +933,7 @@ describe("json tab", () => { }), ).model expect(reread.jsonDraft).toBe(draftText) + scene({ update: pageUpdate, view }, given(reread), expectView(text("changed")).not.toExist()) expect(pageUpdate(reread, Message.ClickedApplyJson()).commands).toMatchObject([ { name: "ApplyStatusPageConfig", args: { ifVersion: "v8" } }, ]) diff --git a/apps/web/src/page/alerts/status-pages/editor/update-json.ts b/apps/web/src/page/alerts/status-pages/editor/update-json.ts index dee435bfb7..917607728d 100644 --- a/apps/web/src/page/alerts/status-pages/editor/update-json.ts +++ b/apps/web/src/page/alerts/status-pages/editor/update-json.ts @@ -1,4 +1,9 @@ -import { normalizeStatusPageConfig, type StatusPageConfig, type V2StatusPage } from "@maple/domain/http/v2" +import { + hasStatusPageConfigChanges, + normalizeStatusPageConfig, + type StatusPageConfig, + type V2StatusPage, +} from "@maple/domain/http/v2" import { Option, Result } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import type { LoadFailure } from "../../../../data/load-failure" @@ -36,12 +41,7 @@ export const draftOf = (model: Model): Option.Option => const hasChanges = (draft: DraftResult): boolean => Result.match(draft, { onFailure: () => false, - onSuccess: ({ changes }) => - changes.fields.length > 0 || - changes.created.length > 0 || - changes.updated.length > 0 || - changes.deleted.length > 0 || - changes.reordered, + onSuccess: ({ changes }) => hasStatusPageConfigChanges(changes), }) /** Apply needs a valid draft that changes something, and no unsaved Settings edits to fight. */ @@ -170,7 +170,12 @@ export const rebaseJsonDraft = (model: Model): Model => model.isRebasingJson && model.jsonDraft !== null ? Option.match(currentConfigOf(model), { onNone: () => model, - onSome: (current) => ({ ...model, jsonBase: current, isRebasingJson: false }), + onSome: (current) => ({ + ...model, + jsonBase: current, + isRebasingJson: false, + jsonFailure: null, + }), }) : model From 98b11248cc3f570a683d24e32a1e6db7657cf896 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 01:58:46 +0100 Subject: [PATCH 17/17] test(status-pages): the config apply audits a change once and a no-op not at all --- .../src/routes/v2/status-pages.http.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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 415fdef8a9..09069b5505 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -7,7 +7,7 @@ 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 { AuditLogService, makeMemoryAuditLog } 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" @@ -17,7 +17,7 @@ import { MobileDevicesService } from "@maple/backend/services/push/MobileDevices import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, - AllV2GroupLayersLive, + allV2GroupLayersWithAudit, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, @@ -54,6 +54,7 @@ const testConfig = () => const makeHarness = () => { const testDb = createTestDb(createdDbs) + const audit = makeMemoryAuditLog() const envLive = Env.layer.pipe(Layer.provide(testConfig())) const servicesLive = Layer.mergeAll( ApiKeysService.layer, @@ -65,7 +66,7 @@ const makeHarness = () => { ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) const routes = HttpApiBuilder.layer(MapleApiV2).pipe( - Layer.provide(AllV2GroupLayersLive), + Layer.provide(allV2GroupLayersWithAudit(Layer.succeed(AuditLogService, audit))), Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), @@ -73,7 +74,7 @@ const makeHarness = () => { Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Live), - Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(Layer.succeed(AuditLogService, audit)), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) @@ -104,9 +105,13 @@ const makeHarness = () => { }), ) + const auditEntries = (action: string) => + Effect.runPromise(audit.list(ORG, { action, limit: 100, offset: 0 })) + return { request, bootstrapKey, + auditEntries, dispose: async () => { await disposeHandler() await runtime.dispose() @@ -503,8 +508,9 @@ describe("v2 status pages", () => { expect(applied.status).toBe(200) expect(applied.body).toEqual(read.body) const after = (await harness.request("GET", `/v2/status_pages/${id}`, token)).body.revision - // Nothing changed, so nothing republishes. + // Nothing changed, so nothing republishes and nothing is audited. expect(after).toBe(before) + expect(await harness.auditEntries("status_page.config_applied")).toEqual([]) })) it("applies created, updated, deleted and reordered components with the page fields", () => @@ -540,6 +546,15 @@ describe("v2 status pages", () => { [apiId, 1], ]) expect(listed.body.data.some((c: { id: string }) => c.id === webId)).toBe(false) + const [entry, ...rest] = await harness.auditEntries("status_page.config_applied") + expect(rest).toEqual([]) + expect(entry?.resourceId).toBe(id) + expect(entry?.metadata).toMatchObject({ + name: "Acme Inc", + components_created: 1, + components_updated: 1, + components_deleted: 1, + }) })) it("refuses an invalid document with a 400 naming the problem and changes nothing", () =>