Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/api/src/routes/v2/status-pages.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,36 @@ describe("v2 status pages", () => {
expect(missing.body.error.code).toBe("status_page_not_found")
}))

it("sets, refuses and clears the footer links", () =>
withHarness(async (harness) => {
const { secret: token } = await harness.bootstrapKey()
const created = await harness.request("POST", "/v2/status_pages", token, {
name: "Acme Status",
slug: "acme-links",
privacy_url: "https://acme.example/privacy",
})
expect(created.status).toBe(200)
expect(created.body).toMatchObject({
privacy_url: "https://acme.example/privacy",
terms_url: null,
})
const path = `/v2/status_pages/${created.body.id}`

const insecure = await harness.request("PATCH", path, token, {
terms_url: "http://acme.example/terms",
})
expect(insecure.status).toBe(400)
expect(insecure.body.error.message).toContain("Terms of service URL")

const updated = await harness.request("PATCH", path, token, {
privacy_url: null,
terms_url: "https://acme.example/terms",
})
expect(updated.body).toMatchObject({ privacy_url: null, terms_url: "https://acme.example/terms" })
const config = await harness.request("GET", `${path}/config`, token)
expect(config.body).toMatchObject({ privacy_url: null, terms_url: "https://acme.example/terms" })
}))

it("refuses a taken slug and an invalid one", () =>
withHarness(async (harness) => {
const { secret: token } = await harness.bootstrapKey()
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/routes/v2/status-pages.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const toV2StatusPage = (page: StatusPageRecord): V2StatusPage => ({
description: page.description,
website_url: page.websiteUrl,
logo_url: page.logoUrl,
privacy_url: page.privacyUrl,
terms_url: page.termsUrl,
theme: page.theme,
show_powered_by: page.showPoweredBy,
auto_incidents: page.autoIncidents,
Expand Down Expand Up @@ -110,6 +112,8 @@ const pageAuditDiff = auditDiff({
"description",
"website_url",
"logo_url",
"privacy_url",
"terms_url",
"theme",
"show_powered_by",
"auto_incidents",
Expand Down Expand Up @@ -192,6 +196,10 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag
? undefined
: { websiteUrl: payload.website_url }),
...(payload.logo_url === undefined ? undefined : { logoUrl: payload.logo_url }),
...(payload.privacy_url === undefined
? undefined
: { privacyUrl: payload.privacy_url }),
...(payload.terms_url === undefined ? undefined : { termsUrl: payload.terms_url }),
...(payload.theme === undefined
? undefined
: { theme: { ...DEFAULT_STATUS_PAGE_THEME, ...payload.theme } }),
Expand Down Expand Up @@ -225,6 +233,10 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag
? undefined
: { websiteUrl: payload.website_url }),
...(payload.logo_url === undefined ? undefined : { logoUrl: payload.logo_url }),
...(payload.privacy_url === undefined
? undefined
: { privacyUrl: payload.privacy_url }),
...(payload.terms_url === undefined ? undefined : { termsUrl: payload.terms_url }),
// Omitted theme fields keep the page's current values.
...(payload.theme === undefined ? undefined : { theme: payload.theme }),
...(payload.show_powered_by === undefined
Expand Down
2 changes: 1 addition & 1 deletion apps/status/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe("serving a page", () => {
}),
)
const html = await (await send(fake, PAGE)).text()
expect(html).toContain("<h1>Major system outage</h1>")
expect(html).toContain("We're experiencing a major outage")
expect(html).toContain("Checkout failing")
expect(html).toContain("Posted from the edge")
expect(html).toContain("Still looking")
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/page/alerts/status-pages/editor/editor.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,12 +426,23 @@ describe("settings tab", () => {
)
})

test("a footer link must use https before the settings save", () => {
scene(
{ update: pageUpdate, view },
given(loaded({ tab: "settings" })),
type(role("textbox", { name: "Terms of service URL" }), "http://acme.example/terms"),
expectView(text("Use an https URL.")).toExist(),
expectView(role("button", { name: "Save" })).toBeDisabled(),
)
})

test("saving sends every field and settles once the page matches", () => {
const edited = [
Message.ChangedThemeColor({ field: "accent", value: "#1A2B3C" }),
Message.PickedThemePreset({ preset: "paper" }),
Message.ChangedSetting({ field: "autoIncidents", value: "publish" }),
Message.ToggledPoweredBy(),
Message.ChangedSetting({ field: "privacyUrl", value: " https://acme.example/privacy " }),
].reduce((model, message) => pageUpdate(model, message).model, loaded({ tab: "settings" }))
const saving = pageUpdate(edited, Message.ClickedSaveSettings())
expect(saving.commands).toMatchObject([
Expand All @@ -445,6 +456,8 @@ describe("settings tab", () => {
show_powered_by: false,
auto_incidents: "publish",
website_url: null,
privacy_url: "https://acme.example/privacy",
terms_url: null,
},
},
},
Expand All @@ -453,6 +466,7 @@ describe("settings tab", () => {
theme: { ...DEFAULT_STATUS_PAGE_THEME, preset: "paper", accent: "#1a2b3c" },
show_powered_by: false,
auto_incidents: "publish",
privacy_url: "https://acme.example/privacy",
})
const done = pageUpdate(
saving.model,
Expand Down
60 changes: 59 additions & 1 deletion apps/web/src/page/alerts/status-pages/editor/form.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { DEFAULT_STATUS_PAGE_THEME } from "@maple/domain/status-page"
import { describe, expect, it } from "vitest"
import { hasSettingsIssues, isSameSettings, type SettingsForm, settingsPayload, themeFormOf } from "./form"
import {
hasSettingsIssues,
isSameSettings,
type SettingsForm,
settingsIssues,
settingsPayload,
themeFormOf,
} from "./form"

const saved: SettingsForm = {
name: "Acme!",
slug: "acme",
description: "",
websiteUrl: "https://acme.example",
privacyUrl: "",
termsUrl: "",
logoUrl: "",
showPoweredBy: true,
autoIncidents: "off",
Expand All @@ -30,6 +39,55 @@ describe("isSameSettings", () => {
})
})

describe("footer links", () => {
it("sends https links trimmed and blank ones as null", () => {
const payload = settingsPayload({
...saved,
privacyUrl: " https://acme.example/privacy ",
termsUrl: " ",
})
expect(payload.privacy_url).toBe("https://acme.example/privacy")
expect(payload.terms_url).toBeNull()
})

it("refuses links the API would refuse", () => {
const issues = settingsIssues({
...saved,
privacyUrl: "http://acme.example/privacy",
termsUrl: "javascript:alert(1)",
})
expect(issues.privacyUrl).toBe("Use an https URL.")
expect(issues.termsUrl).toBe("Use an https URL.")
expect(hasSettingsIssues({ ...saved, termsUrl: "https://acme.example/terms" })).toBe(false)
})

it("sees a changed link as unsaved", () => {
expect(isSameSettings({ ...saved, termsUrl: "https://acme.example/terms" }, saved)).toBe(false)
expect(isSameSettings({ ...saved, termsUrl: " " }, saved)).toBe(true)
})
})

describe("URL fields", () => {
it("refuses what the service refuses: unparseable or not https, on every URL field", () => {
const issues = settingsIssues({
...saved,
websiteUrl: "http://acme.example",
privacyUrl: "https://acme.example:abc",
termsUrl: "https://acme.example/terms",
logoUrl: "",
})
expect(issues.websiteUrl).toBe("Use an https URL.")
expect(issues.privacyUrl).toBe("Use a valid URL, like https://acme.example.")
expect(issues.termsUrl).toBeNull()
expect(issues.logoUrl).toBeNull()
})

it("refuses a link longer than the service stores", () => {
const long = `https://acme.example/${"a".repeat(2048)}`
expect(settingsIssues({ ...saved, termsUrl: long }).termsUrl).toBe("Keep it under 2048 characters.")
})
})

describe("theme settings", () => {
it("sends the whole theme with blank colours as null", () => {
const form: SettingsForm = {
Expand Down
33 changes: 26 additions & 7 deletions apps/web/src/page/alerts/status-pages/editor/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
StatusPageThemeRadius,
type StatusPageTheme,
themeContrastIssue,
STATUS_PAGE_MAX_URL_LENGTH,
} from "@maple/domain/status-page"
import { DateTime, Option, Schema } from "effect"
import { closingStatus, STATUSES_OF_KIND } from "../../shared/status-page-derive"
Expand Down Expand Up @@ -226,6 +227,8 @@ export const SettingsTextField = Schema.Literals([
"slug",
"description",
"websiteUrl",
"privacyUrl",
"termsUrl",
"logoUrl",
"autoIncidents",
])
Expand Down Expand Up @@ -256,6 +259,8 @@ export const SettingsForm = Schema.Struct({
slug: Schema.String,
description: Schema.String,
websiteUrl: Schema.String,
privacyUrl: Schema.String,
termsUrl: Schema.String,
logoUrl: Schema.String,
showPoweredBy: Schema.Boolean,
autoIncidents: StatusPageAutoIncidents,
Expand All @@ -277,6 +282,8 @@ export const settingsFormOf = (page: V2StatusPage): SettingsForm => ({
slug: page.slug,
description: page.description ?? "",
websiteUrl: page.website_url ?? "",
privacyUrl: page.privacy_url ?? "",
termsUrl: page.terms_url ?? "",
logoUrl: page.logo_url ?? "",
showPoweredBy: page.show_powered_by,
autoIncidents: page.auto_incidents,
Expand All @@ -301,6 +308,8 @@ const normalizedSettings = (form: SettingsForm): SettingsForm => ({
name: form.name.trim(),
description: form.description.trim(),
websiteUrl: form.websiteUrl.trim(),
privacyUrl: form.privacyUrl.trim(),
termsUrl: form.termsUrl.trim(),
logoUrl: form.logoUrl.trim(),
theme: themeFormOf(themeOf(form.theme)),
})
Expand All @@ -314,6 +323,8 @@ const sameNormalized = (a: SettingsForm, b: SettingsForm): boolean =>
a.slug === b.slug &&
a.description === b.description &&
a.websiteUrl === b.websiteUrl &&
a.privacyUrl === b.privacyUrl &&
a.termsUrl === b.termsUrl &&
a.logoUrl === b.logoUrl &&
a.showPoweredBy === b.showPoweredBy &&
a.autoIncidents === b.autoIncidents &&
Expand All @@ -329,19 +340,25 @@ const sameTheme = (a: ThemeForm, b: ThemeForm): boolean =>
a.border === b.border &&
a.radius === b.radius &&
a.font === b.font
const isHttpsUrl = (value: string): boolean => /^https:\/\/\S+$/u.test(value)
const isHttpUrl = (value: string): boolean => /^https?:\/\/\S+$/u.test(value)
/** The service's rule: a URL `URL` can parse, over https. Checked here so Save never sends what it refuses. */
const httpsIssue = (value: string): string | null => {
const url = value.trim()
if (url === "") return null
if (url.length > STATUS_PAGE_MAX_URL_LENGTH)
return `Keep it under ${STATUS_PAGE_MAX_URL_LENGTH} characters.`
if (!URL.canParse(url)) return "Use a valid URL, like https://acme.example."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return new URL(url).protocol === "https:" ? null : "Use an https URL."
}

/** Each field's problem, or `null`: the API refuses the same values. */
export const settingsIssues = (form: SettingsForm): Readonly<Record<SettingsTextField, string | null>> => ({
name: form.name.trim() === "" ? "Add a name." : null,
slug: slugIssue(form.slug),
description: null,
websiteUrl:
form.websiteUrl.trim() === "" || isHttpUrl(form.websiteUrl.trim())
? null
: "Use an http or https URL.",
logoUrl: form.logoUrl.trim() === "" || isHttpsUrl(form.logoUrl.trim()) ? null : "Use an https URL.",
websiteUrl: httpsIssue(form.websiteUrl),
privacyUrl: httpsIssue(form.privacyUrl),
termsUrl: httpsIssue(form.termsUrl),
logoUrl: httpsIssue(form.logoUrl),
autoIncidents: null,
})

Expand Down Expand Up @@ -375,6 +392,8 @@ export const settingsPayload = (form: SettingsForm): V2StatusPageUpdateParams =>
slug: form.slug,
description: orNull(form.description),
website_url: orNull(form.websiteUrl),
privacy_url: orNull(form.privacyUrl),
terms_url: orNull(form.termsUrl),
logo_url: orNull(form.logoUrl),
theme: themeOf(form.theme),
show_powered_by: form.showPoweredBy,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/page/alerts/status-pages/editor/update-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ const pageWith = (page: V2StatusPage, applied: StatusPageConfig): V2StatusPage =
description: config.description,
website_url: config.website_url,
logo_url: config.logo_url,
privacy_url: config.privacy_url,
terms_url: config.terms_url,
show_powered_by: config.show_powered_by,
auto_incidents: config.auto_incidents,
theme: config.theme,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ export const previewDocument = (model: Model, page: V2StatusPage, form: Settings
description: orNull(form.description),
websiteUrl: orNull(form.websiteUrl),
logoUrl: orNull(form.logoUrl),
privacyUrl: orNull(form.privacyUrl),
termsUrl: orNull(form.termsUrl),
showPoweredBy: form.showPoweredBy,
theme: previewTheme(themeOf(form.theme), model.previewScheme),
},
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/page/alerts/status-pages/editor/view-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ const pageSection = (h: H, model: Model, form: SettingsForm): Html => {
error: issues.websiteUrl,
onInput: (value) => Message.ChangedSetting({ field: "websiteUrl", value }),
}),
textField(h, {
id: "status-page-privacy",
label: "Privacy policy URL",
value: form.privacyUrl,
placeholder: "https://acme.example/privacy",
isDisabled: isReadOnly,
error: issues.privacyUrl,
hint: "Linked in the page footer.",
onInput: (value) => Message.ChangedSetting({ field: "privacyUrl", value }),
}),
textField(h, {
id: "status-page-terms",
label: "Terms of service URL",
value: form.termsUrl,
placeholder: "https://acme.example/terms",
isDisabled: isReadOnly,
error: issues.termsUrl,
hint: "Linked in the page footer.",
onInput: (value) => Message.ChangedSetting({ field: "termsUrl", value }),
}),
textField(h, {
id: "status-page-logo",
label: "Logo URL",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/page/alerts/status-pages/test-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const statusPage = (fields: Partial<V2StatusPage> = {}): V2StatusPage =>
description: null,
website_url: null,
logo_url: null,
privacy_url: null,
terms_url: null,
theme: DEFAULT_STATUS_PAGE_THEME,
show_powered_by: true,
auto_incidents: "draft",
Expand Down Expand Up @@ -105,6 +107,8 @@ export const statusPageConfig = (
description: page.description,
website_url: page.website_url,
logo_url: page.logo_url,
privacy_url: page.privacy_url,
terms_url: page.terms_url,
show_powered_by: page.show_powered_by,
auto_incidents: page.auto_incidents,
theme: page.theme,
Expand Down
2 changes: 1 addition & 1 deletion docs/api-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a `version`, a hash of the normalized document; the apply ignores that key, but `PUT …/config?if_version=<v>` 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.
`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). Its settings include the optional footer links `privacy_url` and `terms_url` (https only, `null` for none), which `POST`/`PATCH /v2/status_pages` also accept. `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=<v>` 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

Expand Down
Loading
Loading