@checkstack/status-page-common 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/package.json +29 -0
- package/src/access.ts +48 -0
- package/src/index.ts +8 -0
- package/src/plugin-metadata.ts +5 -0
- package/src/public-mappers.ts +38 -0
- package/src/routes.ts +18 -0
- package/src/rpc-contract.ts +140 -0
- package/src/schemas.ts +117 -0
- package/src/select-events.test.ts +74 -0
- package/src/select-events.ts +56 -0
- package/src/widget-types.ts +250 -0
- package/tsconfig.json +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @checkstack/status-page-common
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9ab73c5: Status pages: configurable incident/maintenance updates + recently resolved/completed items.
|
|
8
|
+
|
|
9
|
+
The Incidents and Maintenance widgets gain four config options (in the builder):
|
|
10
|
+
|
|
11
|
+
- **Show updates** (default on) — render the per-item update timeline so visitors
|
|
12
|
+
can follow progress. The maintenance widget now renders its timeline too
|
|
13
|
+
(previously it fetched updates but didn't show them). Turning this off also
|
|
14
|
+
skips the per-item detail fetch (a perf win).
|
|
15
|
+
- **Max updates per item** (default 3) — show only the latest N updates,
|
|
16
|
+
most-recent first, so a chatty incident doesn't dominate the page.
|
|
17
|
+
- **Show recently resolved / completed** (default off) — include resolved
|
|
18
|
+
incidents / completed maintenances, rendered in a separate "Recently resolved"
|
|
19
|
+
/ "Past maintenance" subsection below the active items.
|
|
20
|
+
- **Max age (days)** (default 7) — only include past items resolved/completed
|
|
21
|
+
within the window.
|
|
22
|
+
|
|
23
|
+
Scoping and isolation are unchanged: still only the systems the operator bound,
|
|
24
|
+
still fail-closed when none are bound, still field-allow-listed DTOs (no
|
|
25
|
+
`createdBy`). The active/past partition + max-age + cap is a pure, unit-tested
|
|
26
|
+
helper (`selectEvents`).
|
|
27
|
+
|
|
28
|
+
- 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
|
|
29
|
+
|
|
30
|
+
Operators compose a public status page from widgets (status banner, system
|
|
31
|
+
health, group status, 90-day uptime, incidents, scheduled maintenance) plus
|
|
32
|
+
content blocks (text/Markdown, heading, links, image, divider), each bound to the
|
|
33
|
+
resources they choose, then publish it.
|
|
34
|
+
|
|
35
|
+
Security model — "only published widgets reveal data":
|
|
36
|
+
|
|
37
|
+
- A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
|
|
38
|
+
plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
|
|
39
|
+
has no generic data API, so it can only ever show what was placed on the page.
|
|
40
|
+
- Three gates: edit-time (you can only bind resources you can access), publish-time
|
|
41
|
+
(an audited, deliberate exposure that re-checks the editor can read every bound
|
|
42
|
+
resource via a user-scoped client), and render-time (resolvers run as a trusted
|
|
43
|
+
service but emit only DTO fields — never internal config, ids, or `createdBy`;
|
|
44
|
+
the service re-validates each DTO against its schema, so a resolver bug fails
|
|
45
|
+
closed).
|
|
46
|
+
- The overall banner rolls up only the bound systems; private resources are never
|
|
47
|
+
exposed beyond their public-safe status; per-binding label overrides avoid
|
|
48
|
+
internal-name leaks.
|
|
49
|
+
|
|
50
|
+
Coherence + extensibility:
|
|
51
|
+
|
|
52
|
+
- Status pages are team-scopable resources (RLAC): created via the standard
|
|
53
|
+
owning-team picker + create-capability flow, resolvable by name in the Teams
|
|
54
|
+
admin.
|
|
55
|
+
- Widget types come from an extension-point registry, so any plugin can contribute
|
|
56
|
+
a widget (config schema + public DTO + `resolvePublic`); the public renderers
|
|
57
|
+
are pure, prop-only components with no data access, so third-party widgets can
|
|
58
|
+
never leak.
|
|
59
|
+
- Draft vs published layouts; per-page visibility (public / authenticated-only)
|
|
60
|
+
and theming (brand color, logo).
|
|
61
|
+
|
|
62
|
+
Dependency direction: the status-page platform owns the widget-type registry and
|
|
63
|
+
the content widgets, but the DOMAIN widgets are contributed by their owning
|
|
64
|
+
plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
|
|
65
|
+
banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
|
|
66
|
+
scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
|
|
67
|
+
only on `backend-api` / `common` / `status-page-common`; the owning plugins
|
|
68
|
+
depend on the platform, never the reverse. `catalog-common` gains
|
|
69
|
+
`assertCatalogResourcesReadable` for the publish-time access check.
|
|
70
|
+
|
|
71
|
+
Phase 1 scope: the secure core, the admin builder, and the public page (served as
|
|
72
|
+
a no-access-rule route). A fully separate public bundle, custom domains + TLS,
|
|
73
|
+
drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
|
|
74
|
+
are the next phases.
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [d2077bd]
|
|
79
|
+
- @checkstack/common@0.16.0
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/status-page-common",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/common": "0.16.0",
|
|
13
|
+
"@orpc/contract": "^1.14.4",
|
|
14
|
+
"zod": "^4.2.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.7.2",
|
|
18
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
19
|
+
"@checkstack/scripts": "0.6.2"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "tsgo -b",
|
|
23
|
+
"lint": "bun run lint:code",
|
|
24
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
25
|
+
},
|
|
26
|
+
"checkstack": {
|
|
27
|
+
"type": "common"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { access, accessPair } from "@checkstack/common";
|
|
2
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Access rules for the Status Pages plugin.
|
|
6
|
+
*
|
|
7
|
+
* Two distinct surfaces with DIFFERENT trust models:
|
|
8
|
+
*
|
|
9
|
+
* - `page.read` / `page.manage` gate the AUTHENTICATED admin builder. Pages are
|
|
10
|
+
* team-scopable (RLAC): read is team-filtered, manage (create/edit/publish) is
|
|
11
|
+
* admin or a team with a create-capability grant. NOT public.
|
|
12
|
+
* - `published.read` gates the PUBLIC, anonymous read of a *published* page. It
|
|
13
|
+
* is default-granted to the anonymous role so visitors can see published
|
|
14
|
+
* pages; an admin can revoke it from anonymous to switch OFF public status
|
|
15
|
+
* pages platform-wide without touching the builder. Per-page visibility
|
|
16
|
+
* (public vs authenticated-only) is enforced in the handler, on top of this.
|
|
17
|
+
*/
|
|
18
|
+
export const statusPageAccess = {
|
|
19
|
+
page: accessPair(
|
|
20
|
+
"page",
|
|
21
|
+
{
|
|
22
|
+
read: {
|
|
23
|
+
description: "View status page configurations",
|
|
24
|
+
isDefault: true,
|
|
25
|
+
},
|
|
26
|
+
manage: {
|
|
27
|
+
description: "Create, edit, publish, and delete status pages",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{ pluginId: pluginMetadata.pluginId },
|
|
31
|
+
),
|
|
32
|
+
published: access(
|
|
33
|
+
"published",
|
|
34
|
+
"read",
|
|
35
|
+
"View published status pages (public)",
|
|
36
|
+
{
|
|
37
|
+
pluginId: pluginMetadata.pluginId,
|
|
38
|
+
isDefault: true,
|
|
39
|
+
isPublic: true,
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const statusPageAccessRules = [
|
|
45
|
+
statusPageAccess.page.read,
|
|
46
|
+
statusPageAccess.page.manage,
|
|
47
|
+
statusPageAccess.published,
|
|
48
|
+
];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./plugin-metadata";
|
|
2
|
+
export { statusPageAccess, statusPageAccessRules } from "./access";
|
|
3
|
+
export { statusPageContract, StatusPageApi } from "./rpc-contract";
|
|
4
|
+
export { statusPageRoutes, statusPublicRoutes } from "./routes";
|
|
5
|
+
export * from "./schemas";
|
|
6
|
+
export * from "./widget-types";
|
|
7
|
+
export * from "./public-mappers";
|
|
8
|
+
export * from "./select-events";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, domain-free mappers shared by the owning plugins' status-page
|
|
3
|
+
* widgets. The createdBy-strip is the security-critical part: incident /
|
|
4
|
+
* maintenance update timelines carry `createdBy` / `createdByName` (who on the
|
|
5
|
+
* operating team posted the update) — internal identity that MUST NOT reach the
|
|
6
|
+
* public page. Kept here (no domain deps) so every owning plugin strips it the
|
|
7
|
+
* same, tested, way.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface InternalUpdate {
|
|
11
|
+
message: string;
|
|
12
|
+
statusChange?: string;
|
|
13
|
+
createdAt: string | Date;
|
|
14
|
+
/** Internal — dropped. */
|
|
15
|
+
createdBy?: string;
|
|
16
|
+
createdByName?: string;
|
|
17
|
+
[k: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PublicUpdate {
|
|
21
|
+
message: string;
|
|
22
|
+
statusChange?: string;
|
|
23
|
+
at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Strip an update to its public-safe fields (no author identity). */
|
|
27
|
+
export function toPublicUpdate(update: InternalUpdate): PublicUpdate {
|
|
28
|
+
return {
|
|
29
|
+
message: update.message,
|
|
30
|
+
...(update.statusChange === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { statusChange: update.statusChange }),
|
|
33
|
+
at:
|
|
34
|
+
update.createdAt instanceof Date
|
|
35
|
+
? update.createdAt.toISOString()
|
|
36
|
+
: update.createdAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createRoutes } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/** Admin builder routes (authenticated). */
|
|
4
|
+
export const statusPageRoutes = createRoutes("status-pages", {
|
|
5
|
+
list: "",
|
|
6
|
+
builder: "/:id",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public page route (anonymous). Lives under a distinct `/status` prefix and
|
|
11
|
+
* carries NO access rule, so it renders for unauthenticated visitors and only
|
|
12
|
+
* ever calls the single public `getPublishedStatusPage` endpoint. (A fully
|
|
13
|
+
* separate public bundle / custom-domain host routing is the next phase; the
|
|
14
|
+
* data-isolation guarantee is enforced server-side regardless of bundle.)
|
|
15
|
+
*/
|
|
16
|
+
export const statusPublicRoutes = createRoutes("status", {
|
|
17
|
+
page: "/:slug",
|
|
18
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createClientDefinition, proc } from "@checkstack/common";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { statusPageAccess } from "./access";
|
|
5
|
+
import {
|
|
6
|
+
StatusPageSchema,
|
|
7
|
+
StatusPageSummarySchema,
|
|
8
|
+
StatusPageVisibilitySchema,
|
|
9
|
+
StatusPageThemeSchema,
|
|
10
|
+
StatusPageLayoutSchema,
|
|
11
|
+
StatusPageSlugSchema,
|
|
12
|
+
PublishedStatusPageSchema,
|
|
13
|
+
} from "./schemas";
|
|
14
|
+
import { WidgetTypeDescriptorSchema } from "./widget-types";
|
|
15
|
+
|
|
16
|
+
const TitleSchema = z.string().trim().min(1).max(160);
|
|
17
|
+
|
|
18
|
+
export const statusPageContract = {
|
|
19
|
+
// ----- Admin builder (authenticated, team-scoped) -----
|
|
20
|
+
|
|
21
|
+
listStatusPages: proc({
|
|
22
|
+
operationType: "query",
|
|
23
|
+
userType: "authenticated",
|
|
24
|
+
access: [statusPageAccess.page.read],
|
|
25
|
+
instanceAccess: { listKey: "pages" },
|
|
26
|
+
})
|
|
27
|
+
.input(z.object({}).optional())
|
|
28
|
+
.output(z.object({ pages: z.array(StatusPageSummarySchema) })),
|
|
29
|
+
|
|
30
|
+
getStatusPage: proc({
|
|
31
|
+
operationType: "query",
|
|
32
|
+
userType: "authenticated",
|
|
33
|
+
access: [statusPageAccess.page.read],
|
|
34
|
+
instanceAccess: { idParam: "id" },
|
|
35
|
+
})
|
|
36
|
+
.input(z.object({ id: z.string() }))
|
|
37
|
+
.output(StatusPageSchema.nullable()),
|
|
38
|
+
|
|
39
|
+
createStatusPage: proc({
|
|
40
|
+
operationType: "mutation",
|
|
41
|
+
userType: "authenticated",
|
|
42
|
+
access: [statusPageAccess.page.manage],
|
|
43
|
+
// Create-mode team ownership: middleware resolves the owning team from
|
|
44
|
+
// `teamId` (create-capability grant or global manage) and writes the owner
|
|
45
|
+
// relation keyed by `statuspage.page` / `response.id` post-handler.
|
|
46
|
+
instanceAccess: { create: { teamIdParam: "teamId", idField: "id" } },
|
|
47
|
+
})
|
|
48
|
+
.input(
|
|
49
|
+
z.object({
|
|
50
|
+
title: TitleSchema,
|
|
51
|
+
slug: StatusPageSlugSchema,
|
|
52
|
+
teamId: z.string().optional(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.output(StatusPageSchema),
|
|
56
|
+
|
|
57
|
+
updateStatusPage: proc({
|
|
58
|
+
operationType: "mutation",
|
|
59
|
+
userType: "authenticated",
|
|
60
|
+
access: [statusPageAccess.page.manage],
|
|
61
|
+
instanceAccess: { idParam: "id" },
|
|
62
|
+
})
|
|
63
|
+
.input(
|
|
64
|
+
z.object({
|
|
65
|
+
id: z.string(),
|
|
66
|
+
title: TitleSchema.optional(),
|
|
67
|
+
slug: StatusPageSlugSchema.optional(),
|
|
68
|
+
visibility: StatusPageVisibilitySchema.optional(),
|
|
69
|
+
theme: StatusPageThemeSchema.optional(),
|
|
70
|
+
draftLayout: StatusPageLayoutSchema.optional(),
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
.output(StatusPageSchema),
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Snapshot the draft layout into the published layout. The middleware checks
|
|
77
|
+
* `manage` on the page; the handler additionally verifies the editor can
|
|
78
|
+
* access every resource a widget binds (you cannot publish what you cannot
|
|
79
|
+
* see) and emits an audit event (the deliberate public-exposure record).
|
|
80
|
+
*/
|
|
81
|
+
publishStatusPage: proc({
|
|
82
|
+
operationType: "mutation",
|
|
83
|
+
userType: "authenticated",
|
|
84
|
+
access: [statusPageAccess.page.manage],
|
|
85
|
+
instanceAccess: { idParam: "id" },
|
|
86
|
+
})
|
|
87
|
+
.input(z.object({ id: z.string() }))
|
|
88
|
+
.output(StatusPageSchema),
|
|
89
|
+
|
|
90
|
+
unpublishStatusPage: proc({
|
|
91
|
+
operationType: "mutation",
|
|
92
|
+
userType: "authenticated",
|
|
93
|
+
access: [statusPageAccess.page.manage],
|
|
94
|
+
instanceAccess: { idParam: "id" },
|
|
95
|
+
})
|
|
96
|
+
.input(z.object({ id: z.string() }))
|
|
97
|
+
.output(StatusPageSchema),
|
|
98
|
+
|
|
99
|
+
deleteStatusPage: proc({
|
|
100
|
+
operationType: "mutation",
|
|
101
|
+
userType: "authenticated",
|
|
102
|
+
access: [statusPageAccess.page.manage],
|
|
103
|
+
instanceAccess: { idParam: "id" },
|
|
104
|
+
})
|
|
105
|
+
.input(z.object({ id: z.string() }))
|
|
106
|
+
.output(z.object({ deleted: z.boolean() })),
|
|
107
|
+
|
|
108
|
+
/** Widget catalogue for the builder (built-ins + plugin-contributed types). */
|
|
109
|
+
listWidgetTypes: proc({
|
|
110
|
+
operationType: "query",
|
|
111
|
+
userType: "authenticated",
|
|
112
|
+
access: [statusPageAccess.page.read],
|
|
113
|
+
instanceAccess: { global: true },
|
|
114
|
+
})
|
|
115
|
+
.input(z.object({}).optional())
|
|
116
|
+
.output(z.object({ widgetTypes: z.array(WidgetTypeDescriptorSchema) })),
|
|
117
|
+
|
|
118
|
+
// ----- Public surface (the ONLY public data endpoint) -----
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a PUBLISHED page for the public renderer. `userType: "public"` gated
|
|
122
|
+
* by the anonymous `published.read` grant; NOT team-scoped (team ownership
|
|
123
|
+
* governs editing, not viewing). The handler enforces published + per-page
|
|
124
|
+
* visibility, and returns ONLY the resolved, field-allow-listed widget DTOs —
|
|
125
|
+
* never a generic data API. Returns null when no published page matches.
|
|
126
|
+
*/
|
|
127
|
+
getPublishedStatusPage: proc({
|
|
128
|
+
operationType: "query",
|
|
129
|
+
userType: "public",
|
|
130
|
+
access: [statusPageAccess.published],
|
|
131
|
+
instanceAccess: { global: true },
|
|
132
|
+
})
|
|
133
|
+
.input(z.object({ slug: z.string() }))
|
|
134
|
+
.output(PublishedStatusPageSchema.nullable()),
|
|
135
|
+
} as const;
|
|
136
|
+
|
|
137
|
+
export const StatusPageApi = createClientDefinition(
|
|
138
|
+
statusPageContract,
|
|
139
|
+
pluginMetadata,
|
|
140
|
+
);
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HttpUrlSchema } from "./widget-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Page visibility. `public` = anyone (gated by the anonymous `published.read`
|
|
6
|
+
* grant). `authenticated` = any logged-in user (an internal status page). Higher
|
|
7
|
+
* tiers (password / IP / SSO) are a later phase.
|
|
8
|
+
*/
|
|
9
|
+
export const StatusPageVisibilitySchema = z.enum(["public", "authenticated"]);
|
|
10
|
+
export type StatusPageVisibility = z.infer<typeof StatusPageVisibilitySchema>;
|
|
11
|
+
|
|
12
|
+
/** Per-page branding. Colors are HSL triples ("262 83% 58%") to match the design tokens. */
|
|
13
|
+
export const StatusPageThemeSchema = z.object({
|
|
14
|
+
brandColorHsl: z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(/^\d{1,3} \d{1,3}% \d{1,3}%$/)
|
|
17
|
+
.optional(),
|
|
18
|
+
logoUrl: HttpUrlSchema.optional(),
|
|
19
|
+
mode: z.enum(["light", "dark", "auto"]).default("auto"),
|
|
20
|
+
});
|
|
21
|
+
export type StatusPageTheme = z.infer<typeof StatusPageThemeSchema>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One block in a page layout. `config` is validated PER-TYPE by the backend
|
|
25
|
+
* widget registry (the contract keeps it opaque so new widget types need no
|
|
26
|
+
* contract change). `label` is an optional public heading for the block.
|
|
27
|
+
*/
|
|
28
|
+
export const StatusPageBlockSchema = z.object({
|
|
29
|
+
id: z.string().min(1),
|
|
30
|
+
type: z.string().min(1),
|
|
31
|
+
label: z.string().trim().max(160).optional(),
|
|
32
|
+
config: z.unknown(),
|
|
33
|
+
});
|
|
34
|
+
export type StatusPageBlock = z.infer<typeof StatusPageBlockSchema>;
|
|
35
|
+
|
|
36
|
+
export const StatusPageLayoutSchema = z
|
|
37
|
+
.array(StatusPageBlockSchema)
|
|
38
|
+
.max(100)
|
|
39
|
+
.superRefine((blocks, ctx) => {
|
|
40
|
+
// Block ids are client-generated; reject duplicates so a layout can't carry
|
|
41
|
+
// two blocks with the same id (which would break React keys + per-block ops).
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
for (const block of blocks) {
|
|
44
|
+
if (seen.has(block.id)) {
|
|
45
|
+
ctx.addIssue({
|
|
46
|
+
code: z.ZodIssueCode.custom,
|
|
47
|
+
message: `Duplicate block id: ${block.id}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
seen.add(block.id);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
export type StatusPageLayout = z.infer<typeof StatusPageLayoutSchema>;
|
|
54
|
+
|
|
55
|
+
/** Slug: lowercase, url-safe; the public page lives at /status/<slug>. */
|
|
56
|
+
export const StatusPageSlugSchema = z
|
|
57
|
+
.string()
|
|
58
|
+
.trim()
|
|
59
|
+
.min(1)
|
|
60
|
+
.max(63)
|
|
61
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and dashes");
|
|
62
|
+
|
|
63
|
+
/** Full admin-facing status page (the builder's working copy). */
|
|
64
|
+
export const StatusPageSchema = z.object({
|
|
65
|
+
id: z.string(),
|
|
66
|
+
slug: z.string(),
|
|
67
|
+
title: z.string(),
|
|
68
|
+
visibility: StatusPageVisibilitySchema,
|
|
69
|
+
theme: StatusPageThemeSchema,
|
|
70
|
+
draftLayout: StatusPageLayoutSchema,
|
|
71
|
+
publishedLayout: StatusPageLayoutSchema.nullable(),
|
|
72
|
+
published: z.boolean(),
|
|
73
|
+
publishedAt: z.string().nullable(),
|
|
74
|
+
createdAt: z.string(),
|
|
75
|
+
updatedAt: z.string(),
|
|
76
|
+
});
|
|
77
|
+
export type StatusPage = z.infer<typeof StatusPageSchema>;
|
|
78
|
+
|
|
79
|
+
/** Lightweight summary for the list view. */
|
|
80
|
+
export const StatusPageSummarySchema = z.object({
|
|
81
|
+
id: z.string(),
|
|
82
|
+
slug: z.string(),
|
|
83
|
+
title: z.string(),
|
|
84
|
+
visibility: StatusPageVisibilitySchema,
|
|
85
|
+
published: z.boolean(),
|
|
86
|
+
publishedAt: z.string().nullable(),
|
|
87
|
+
updatedAt: z.string(),
|
|
88
|
+
});
|
|
89
|
+
export type StatusPageSummary = z.infer<typeof StatusPageSummarySchema>;
|
|
90
|
+
|
|
91
|
+
// ===========================================================================
|
|
92
|
+
// Public output (the ONLY shape the public surface ever receives)
|
|
93
|
+
// ===========================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A block as rendered publicly: its type + optional label + the resolver's
|
|
97
|
+
* field-allow-listed `data` DTO. `data` is `unknown` at the contract boundary
|
|
98
|
+
* (each widget type defines its own DTO); the resolver validates against the
|
|
99
|
+
* per-type DTO schema before emitting.
|
|
100
|
+
*/
|
|
101
|
+
export const ResolvedBlockSchema = z.object({
|
|
102
|
+
id: z.string(),
|
|
103
|
+
type: z.string(),
|
|
104
|
+
label: z.string().optional(),
|
|
105
|
+
data: z.unknown(),
|
|
106
|
+
});
|
|
107
|
+
export type ResolvedBlock = z.infer<typeof ResolvedBlockSchema>;
|
|
108
|
+
|
|
109
|
+
export const PublishedStatusPageSchema = z.object({
|
|
110
|
+
slug: z.string(),
|
|
111
|
+
title: z.string(),
|
|
112
|
+
theme: StatusPageThemeSchema,
|
|
113
|
+
blocks: z.array(ResolvedBlockSchema),
|
|
114
|
+
/** When the resolver assembled this snapshot (drives client cache hints). */
|
|
115
|
+
generatedAt: z.string(),
|
|
116
|
+
});
|
|
117
|
+
export type PublishedStatusPage = z.infer<typeof PublishedStatusPageSchema>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { selectEvents } from "./select-events";
|
|
3
|
+
|
|
4
|
+
interface Item {
|
|
5
|
+
id: string;
|
|
6
|
+
resolved: boolean;
|
|
7
|
+
at: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const NOW = new Date("2026-06-11T00:00:00.000Z").getTime();
|
|
11
|
+
const daysAgo = (d: number) =>
|
|
12
|
+
new Date(NOW - d * 86_400_000).toISOString();
|
|
13
|
+
|
|
14
|
+
const items: Item[] = [
|
|
15
|
+
{ id: "a1", resolved: false, at: daysAgo(1) },
|
|
16
|
+
{ id: "a2", resolved: false, at: daysAgo(3) },
|
|
17
|
+
{ id: "p-recent", resolved: true, at: daysAgo(2) },
|
|
18
|
+
{ id: "p-old", resolved: true, at: daysAgo(20) },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
items,
|
|
23
|
+
isPast: (i: Item) => i.resolved,
|
|
24
|
+
timestampOf: (i: Item) => i.at,
|
|
25
|
+
limit: 5,
|
|
26
|
+
now: NOW,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("selectEvents", () => {
|
|
30
|
+
test("includePast=false returns only active, newest first", () => {
|
|
31
|
+
const { active, past } = selectEvents({
|
|
32
|
+
...base,
|
|
33
|
+
includePast: false,
|
|
34
|
+
pastMaxAgeDays: 7,
|
|
35
|
+
});
|
|
36
|
+
expect(active.map((i) => i.id)).toEqual(["a1", "a2"]);
|
|
37
|
+
expect(past).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("includePast=true keeps past within the max age, drops older", () => {
|
|
41
|
+
const { active, past } = selectEvents({
|
|
42
|
+
...base,
|
|
43
|
+
includePast: true,
|
|
44
|
+
pastMaxAgeDays: 7,
|
|
45
|
+
});
|
|
46
|
+
expect(active.map((i) => i.id)).toEqual(["a1", "a2"]);
|
|
47
|
+
expect(past.map((i) => i.id)).toEqual(["p-recent"]); // p-old (20d) excluded
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a wider max age includes older past items", () => {
|
|
51
|
+
const { past } = selectEvents({
|
|
52
|
+
...base,
|
|
53
|
+
includePast: true,
|
|
54
|
+
pastMaxAgeDays: 30,
|
|
55
|
+
});
|
|
56
|
+
expect(past.map((i) => i.id)).toEqual(["p-recent", "p-old"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("limit caps each group independently", () => {
|
|
60
|
+
const many: Item[] = Array.from({ length: 8 }, (_, i) => ({
|
|
61
|
+
id: `a${i}`,
|
|
62
|
+
resolved: false,
|
|
63
|
+
at: daysAgo(i),
|
|
64
|
+
}));
|
|
65
|
+
const { active } = selectEvents({
|
|
66
|
+
...base,
|
|
67
|
+
items: many,
|
|
68
|
+
includePast: false,
|
|
69
|
+
pastMaxAgeDays: 7,
|
|
70
|
+
limit: 3,
|
|
71
|
+
});
|
|
72
|
+
expect(active).toHaveLength(3);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure selection for the event-feed widgets (incidents, maintenance): split a
|
|
3
|
+
* list into ACTIVE and recently-PAST items, age-filter the past ones, sort each
|
|
4
|
+
* group newest-first, and cap both by `limit`. Domain-agnostic — the caller
|
|
5
|
+
* supplies the "is this past?" predicate and the timestamp accessor — so it is
|
|
6
|
+
* unit-tested once and reused by both backends.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SelectEventsArgs<T> {
|
|
10
|
+
items: T[];
|
|
11
|
+
/** True for a resolved incident / completed maintenance. */
|
|
12
|
+
isPast: (item: T) => boolean;
|
|
13
|
+
/** Timestamp used to sort (newest first) AND to age-filter past items. */
|
|
14
|
+
timestampOf: (item: T) => string | Date;
|
|
15
|
+
includePast: boolean;
|
|
16
|
+
pastMaxAgeDays: number;
|
|
17
|
+
limit: number;
|
|
18
|
+
/** `Date.now()` at the call site (injected so the logic stays pure/testable). */
|
|
19
|
+
now: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SelectedEvents<T> {
|
|
23
|
+
active: T[];
|
|
24
|
+
past: T[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ms(value: string | Date): number {
|
|
28
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function selectEvents<T>({
|
|
32
|
+
items,
|
|
33
|
+
isPast,
|
|
34
|
+
timestampOf,
|
|
35
|
+
includePast,
|
|
36
|
+
pastMaxAgeDays,
|
|
37
|
+
limit,
|
|
38
|
+
now,
|
|
39
|
+
}: SelectEventsArgs<T>): SelectedEvents<T> {
|
|
40
|
+
const byNewest = (a: T, b: T) => ms(timestampOf(b)) - ms(timestampOf(a));
|
|
41
|
+
|
|
42
|
+
const active = items
|
|
43
|
+
.filter((i) => !isPast(i))
|
|
44
|
+
.toSorted(byNewest)
|
|
45
|
+
.slice(0, limit);
|
|
46
|
+
|
|
47
|
+
if (!includePast) return { active, past: [] };
|
|
48
|
+
|
|
49
|
+
const cutoff = now - pastMaxAgeDays * 86_400_000;
|
|
50
|
+
const past = items
|
|
51
|
+
.filter((i) => isPast(i) && ms(timestampOf(i)) >= cutoff)
|
|
52
|
+
.toSorted(byNewest)
|
|
53
|
+
.slice(0, limit);
|
|
54
|
+
|
|
55
|
+
return { active, past };
|
|
56
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An http(s) URL. Status pages render these as `<a href>` / `<img src>` on a
|
|
5
|
+
* public surface, so disallow `javascript:` / `data:` / `file:` schemes that
|
|
6
|
+
* `z.string().url()` would otherwise accept (operator-side stored-XSS guard).
|
|
7
|
+
*/
|
|
8
|
+
export const HttpUrlSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.url()
|
|
11
|
+
.refine((v) => /^https?:\/\//i.test(v), {
|
|
12
|
+
message: "Must be an http(s) URL",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Built-in widget catalogue for status pages. Each widget has:
|
|
17
|
+
* - a CONFIG schema (what an operator edits + what is stored in the layout), and
|
|
18
|
+
* - a public DTO schema (the field-ALLOW-LISTED shape the public resolver emits).
|
|
19
|
+
*
|
|
20
|
+
* The DTO is the security boundary: a resolver MUST only ever emit DTO fields,
|
|
21
|
+
* never the source resource's internal config / ids / `createdBy`. New plugins
|
|
22
|
+
* contribute additional widget types via the backend widget-type registry; these
|
|
23
|
+
* are the built-ins shipped by the platform.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Industry-standard public status vocabulary (Statuspage/Instatus/Cachet). The
|
|
28
|
+
* INTERNAL health enum (healthy/degraded/unhealthy) is mapped onto this; the
|
|
29
|
+
* public page never exposes the internal enum.
|
|
30
|
+
*/
|
|
31
|
+
export const PublicStatusSchema = z.enum([
|
|
32
|
+
"operational",
|
|
33
|
+
"degraded",
|
|
34
|
+
"partial_outage",
|
|
35
|
+
"major_outage",
|
|
36
|
+
"maintenance",
|
|
37
|
+
"unknown",
|
|
38
|
+
]);
|
|
39
|
+
export type PublicStatus = z.infer<typeof PublicStatusSchema>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A bound system with an OPTIONAL public label override. Defaulting to the
|
|
43
|
+
* system's own name risks leaking an internal name (e.g. `prod-db-01.internal`);
|
|
44
|
+
* the override lets operators present a clean public label.
|
|
45
|
+
*/
|
|
46
|
+
export const SystemBindingSchema = z.object({
|
|
47
|
+
systemId: z.string().min(1),
|
|
48
|
+
label: z.string().trim().max(120).optional(),
|
|
49
|
+
});
|
|
50
|
+
export type SystemBinding = z.infer<typeof SystemBindingSchema>;
|
|
51
|
+
|
|
52
|
+
// ===========================================================================
|
|
53
|
+
// Config schemas (stored in the layout; admin-edited)
|
|
54
|
+
// ===========================================================================
|
|
55
|
+
|
|
56
|
+
export const BannerConfigSchema = z.object({
|
|
57
|
+
/** Systems whose health rolls up into the overall banner. Empty = no rollup. */
|
|
58
|
+
systemIds: z.array(z.string().min(1)).default([]),
|
|
59
|
+
/** Optional title override (else a status-derived label is shown). */
|
|
60
|
+
title: z.string().trim().max(160).optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const SystemHealthConfigSchema = z.object({
|
|
64
|
+
items: z.array(SystemBindingSchema).default([]),
|
|
65
|
+
/** Show a small uptime % next to each system. */
|
|
66
|
+
showUptime: z.boolean().default(false),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const GroupStatusConfigSchema = z.object({
|
|
70
|
+
groupId: z.string().min(1),
|
|
71
|
+
label: z.string().trim().max(120).optional(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const UptimeConfigSchema = z.object({
|
|
75
|
+
systemId: z.string().min(1),
|
|
76
|
+
label: z.string().trim().max(120).optional(),
|
|
77
|
+
/** Days of history to show as daily bars (status-page standard is 90). */
|
|
78
|
+
days: z.number().int().min(1).max(90).default(90),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Shared config for the event-feed widgets (incidents, maintenance): whether to
|
|
83
|
+
* show the update timeline + how many, and whether to include recently
|
|
84
|
+
* resolved/completed items within a max age.
|
|
85
|
+
*/
|
|
86
|
+
export const EventFeedConfigSchema = z.object({
|
|
87
|
+
/** Render the per-item update timeline. */
|
|
88
|
+
showUpdates: z.boolean().default(true),
|
|
89
|
+
/** Cap the latest updates shown per item. */
|
|
90
|
+
maxUpdates: z.number().int().min(1).max(10).default(3),
|
|
91
|
+
/** Include resolved incidents / completed maintenances (not just active). */
|
|
92
|
+
includePast: z.boolean().default(false),
|
|
93
|
+
/** Only past items resolved/completed within the last N days. */
|
|
94
|
+
pastMaxAgeDays: z.number().int().min(1).max(90).default(7),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const IncidentsConfigSchema = EventFeedConfigSchema.extend({
|
|
98
|
+
/** Restrict to incidents touching these systems. Empty = all visible. */
|
|
99
|
+
systemIds: z.array(z.string().min(1)).default([]),
|
|
100
|
+
limit: z.number().int().min(1).max(20).default(5),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const MaintenanceConfigSchema = EventFeedConfigSchema.extend({
|
|
104
|
+
systemIds: z.array(z.string().min(1)).default([]),
|
|
105
|
+
limit: z.number().int().min(1).max(20).default(5),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const TextConfigSchema = z.object({
|
|
109
|
+
/** Markdown, rendered SANITIZED on the public page (no raw HTML/JS). */
|
|
110
|
+
markdown: z.string().max(20_000).default(""),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const HeadingConfigSchema = z.object({
|
|
114
|
+
text: z.string().trim().max(200).default(""),
|
|
115
|
+
level: z.number().int().min(1).max(3).default(2),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const LinkSchema = z.object({
|
|
119
|
+
label: z.string().trim().min(1).max(120),
|
|
120
|
+
url: HttpUrlSchema,
|
|
121
|
+
});
|
|
122
|
+
export const LinksConfigSchema = z.object({
|
|
123
|
+
links: z.array(LinkSchema).max(20).default([]),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const ImageConfigSchema = z.object({
|
|
127
|
+
url: HttpUrlSchema,
|
|
128
|
+
alt: z.string().trim().max(200).optional(),
|
|
129
|
+
maxHeight: z.number().int().min(16).max(400).optional(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const DividerConfigSchema = z.object({});
|
|
133
|
+
|
|
134
|
+
// ===========================================================================
|
|
135
|
+
// Public DTO schemas (resolver output; the field allow-list)
|
|
136
|
+
// ===========================================================================
|
|
137
|
+
|
|
138
|
+
export const StatusItemDtoSchema = z.object({
|
|
139
|
+
label: z.string(),
|
|
140
|
+
status: PublicStatusSchema,
|
|
141
|
+
uptimePct: z.number().optional(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const BannerDtoSchema = z.object({
|
|
145
|
+
status: PublicStatusSchema,
|
|
146
|
+
title: z.string(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const SystemHealthDtoSchema = z.object({
|
|
150
|
+
systems: z.array(StatusItemDtoSchema),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const GroupStatusDtoSchema = z.object({
|
|
154
|
+
label: z.string(),
|
|
155
|
+
status: PublicStatusSchema,
|
|
156
|
+
systems: z.array(StatusItemDtoSchema),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const UptimeBarSchema = z.object({
|
|
160
|
+
date: z.string(),
|
|
161
|
+
uptimePct: z.number(),
|
|
162
|
+
status: PublicStatusSchema,
|
|
163
|
+
});
|
|
164
|
+
export const UptimeDtoSchema = z.object({
|
|
165
|
+
label: z.string(),
|
|
166
|
+
uptimePct: z.number(),
|
|
167
|
+
bars: z.array(UptimeBarSchema),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** A public-safe timeline update: NO `createdBy` (that is an internal leak). */
|
|
171
|
+
export const PublicUpdateSchema = z.object({
|
|
172
|
+
message: z.string(),
|
|
173
|
+
statusChange: z.string().optional(),
|
|
174
|
+
at: z.string(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const IncidentDtoItemSchema = z.object({
|
|
178
|
+
id: z.string(),
|
|
179
|
+
title: z.string(),
|
|
180
|
+
status: z.string(),
|
|
181
|
+
severity: z.string(),
|
|
182
|
+
systems: z.array(z.string()),
|
|
183
|
+
startedAt: z.string(),
|
|
184
|
+
/** When the incident was resolved (present only for resolved incidents). */
|
|
185
|
+
resolvedAt: z.string().optional(),
|
|
186
|
+
updates: z.array(PublicUpdateSchema),
|
|
187
|
+
});
|
|
188
|
+
export const IncidentsDtoSchema = z.object({
|
|
189
|
+
incidents: z.array(IncidentDtoItemSchema),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export const MaintenanceDtoItemSchema = z.object({
|
|
193
|
+
id: z.string(),
|
|
194
|
+
title: z.string(),
|
|
195
|
+
status: z.string(),
|
|
196
|
+
startAt: z.string(),
|
|
197
|
+
endAt: z.string(),
|
|
198
|
+
systems: z.array(z.string()),
|
|
199
|
+
updates: z.array(PublicUpdateSchema),
|
|
200
|
+
});
|
|
201
|
+
export const MaintenanceDtoSchema = z.object({
|
|
202
|
+
maintenances: z.array(MaintenanceDtoItemSchema),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export const TextDtoSchema = z.object({ markdown: z.string() });
|
|
206
|
+
export const HeadingDtoSchema = z.object({
|
|
207
|
+
text: z.string(),
|
|
208
|
+
level: z.number(),
|
|
209
|
+
});
|
|
210
|
+
export const LinksDtoSchema = LinksConfigSchema;
|
|
211
|
+
export const ImageDtoSchema = ImageConfigSchema;
|
|
212
|
+
export const DividerDtoSchema = z.object({});
|
|
213
|
+
|
|
214
|
+
// ===========================================================================
|
|
215
|
+
// Catalogue: ids + metadata the builder lists
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
|
|
218
|
+
/** What a widget binds to (drives the builder's resource-picker + edit-time authz). */
|
|
219
|
+
export const WidgetBindingKindSchema = z.enum([
|
|
220
|
+
"none",
|
|
221
|
+
"system",
|
|
222
|
+
"systems",
|
|
223
|
+
"group",
|
|
224
|
+
]);
|
|
225
|
+
export type WidgetBindingKind = z.infer<typeof WidgetBindingKindSchema>;
|
|
226
|
+
|
|
227
|
+
export const WidgetTypeDescriptorSchema = z.object({
|
|
228
|
+
/** Qualified id, e.g. "statuspage.systemHealth". */
|
|
229
|
+
id: z.string(),
|
|
230
|
+
displayName: z.string(),
|
|
231
|
+
description: z.string(),
|
|
232
|
+
category: z.string(),
|
|
233
|
+
binding: WidgetBindingKindSchema,
|
|
234
|
+
});
|
|
235
|
+
export type WidgetTypeDescriptor = z.infer<typeof WidgetTypeDescriptorSchema>;
|
|
236
|
+
|
|
237
|
+
/** Stable ids for the built-in widget types (qualified by the plugin id). */
|
|
238
|
+
export const BUILTIN_WIDGET_IDS = {
|
|
239
|
+
banner: "statuspage.banner",
|
|
240
|
+
systemHealth: "statuspage.systemHealth",
|
|
241
|
+
groupStatus: "statuspage.groupStatus",
|
|
242
|
+
uptime: "statuspage.uptime",
|
|
243
|
+
incidents: "statuspage.incidents",
|
|
244
|
+
maintenance: "statuspage.maintenance",
|
|
245
|
+
text: "statuspage.text",
|
|
246
|
+
heading: "statuspage.heading",
|
|
247
|
+
links: "statuspage.links",
|
|
248
|
+
image: "statuspage.image",
|
|
249
|
+
divider: "statuspage.divider",
|
|
250
|
+
} as const;
|