@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 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,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "statuspage",
5
+ });
@@ -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;
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../common"
9
+ }
10
+ ]
11
+ }