@checkstack/status-page-backend 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,62 @@
1
+ # @checkstack/status-page-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
8
+
9
+ Operators compose a public status page from widgets (status banner, system
10
+ health, group status, 90-day uptime, incidents, scheduled maintenance) plus
11
+ content blocks (text/Markdown, heading, links, image, divider), each bound to the
12
+ resources they choose, then publish it.
13
+
14
+ Security model — "only published widgets reveal data":
15
+
16
+ - A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
17
+ plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
18
+ has no generic data API, so it can only ever show what was placed on the page.
19
+ - Three gates: edit-time (you can only bind resources you can access), publish-time
20
+ (an audited, deliberate exposure that re-checks the editor can read every bound
21
+ resource via a user-scoped client), and render-time (resolvers run as a trusted
22
+ service but emit only DTO fields — never internal config, ids, or `createdBy`;
23
+ the service re-validates each DTO against its schema, so a resolver bug fails
24
+ closed).
25
+ - The overall banner rolls up only the bound systems; private resources are never
26
+ exposed beyond their public-safe status; per-binding label overrides avoid
27
+ internal-name leaks.
28
+
29
+ Coherence + extensibility:
30
+
31
+ - Status pages are team-scopable resources (RLAC): created via the standard
32
+ owning-team picker + create-capability flow, resolvable by name in the Teams
33
+ admin.
34
+ - Widget types come from an extension-point registry, so any plugin can contribute
35
+ a widget (config schema + public DTO + `resolvePublic`); the public renderers
36
+ are pure, prop-only components with no data access, so third-party widgets can
37
+ never leak.
38
+ - Draft vs published layouts; per-page visibility (public / authenticated-only)
39
+ and theming (brand color, logo).
40
+
41
+ Dependency direction: the status-page platform owns the widget-type registry and
42
+ the content widgets, but the DOMAIN widgets are contributed by their owning
43
+ plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
44
+ banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
45
+ scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
46
+ only on `backend-api` / `common` / `status-page-common`; the owning plugins
47
+ depend on the platform, never the reverse. `catalog-common` gains
48
+ `assertCatalogResourcesReadable` for the publish-time access check.
49
+
50
+ Phase 1 scope: the secure core, the admin builder, and the public page (served as
51
+ a no-access-rule route). A fully separate public bundle, custom domains + TLS,
52
+ drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
53
+ are the next phases.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [d2077bd]
58
+ - Updated dependencies [9ab73c5]
59
+ - Updated dependencies [5c6393f]
60
+ - @checkstack/backend-api@0.23.0
61
+ - @checkstack/common@0.16.0
62
+ - @checkstack/status-page-common@0.1.0
@@ -0,0 +1,14 @@
1
+ CREATE TABLE "status_pages" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "slug" text NOT NULL,
4
+ "title" text NOT NULL,
5
+ "visibility" text DEFAULT 'public' NOT NULL,
6
+ "theme" jsonb DEFAULT '{"mode":"auto"}'::jsonb NOT NULL,
7
+ "draft_layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
8
+ "published_layout" jsonb,
9
+ "published_at" timestamp,
10
+ "created_at" timestamp DEFAULT now() NOT NULL,
11
+ "updated_at" timestamp DEFAULT now() NOT NULL
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE UNIQUE INDEX "status_pages_slug_unique" ON "status_pages" USING btree ("slug");
@@ -0,0 +1,113 @@
1
+ {
2
+ "id": "ed199f30-548d-4957-9e63-87484bfc0d39",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.status_pages": {
8
+ "name": "status_pages",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "slug": {
18
+ "name": "slug",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "title": {
24
+ "name": "title",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "visibility": {
30
+ "name": "visibility",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": true,
34
+ "default": "'public'"
35
+ },
36
+ "theme": {
37
+ "name": "theme",
38
+ "type": "jsonb",
39
+ "primaryKey": false,
40
+ "notNull": true,
41
+ "default": "'{\"mode\":\"auto\"}'::jsonb"
42
+ },
43
+ "draft_layout": {
44
+ "name": "draft_layout",
45
+ "type": "jsonb",
46
+ "primaryKey": false,
47
+ "notNull": true,
48
+ "default": "'[]'::jsonb"
49
+ },
50
+ "published_layout": {
51
+ "name": "published_layout",
52
+ "type": "jsonb",
53
+ "primaryKey": false,
54
+ "notNull": false
55
+ },
56
+ "published_at": {
57
+ "name": "published_at",
58
+ "type": "timestamp",
59
+ "primaryKey": false,
60
+ "notNull": false
61
+ },
62
+ "created_at": {
63
+ "name": "created_at",
64
+ "type": "timestamp",
65
+ "primaryKey": false,
66
+ "notNull": true,
67
+ "default": "now()"
68
+ },
69
+ "updated_at": {
70
+ "name": "updated_at",
71
+ "type": "timestamp",
72
+ "primaryKey": false,
73
+ "notNull": true,
74
+ "default": "now()"
75
+ }
76
+ },
77
+ "indexes": {
78
+ "status_pages_slug_unique": {
79
+ "name": "status_pages_slug_unique",
80
+ "columns": [
81
+ {
82
+ "expression": "slug",
83
+ "isExpression": false,
84
+ "asc": true,
85
+ "nulls": "last"
86
+ }
87
+ ],
88
+ "isUnique": true,
89
+ "concurrently": false,
90
+ "method": "btree",
91
+ "with": {}
92
+ }
93
+ },
94
+ "foreignKeys": {},
95
+ "compositePrimaryKeys": {},
96
+ "uniqueConstraints": {},
97
+ "policies": {},
98
+ "checkConstraints": {},
99
+ "isRLSEnabled": false
100
+ }
101
+ },
102
+ "enums": {},
103
+ "schemas": {},
104
+ "sequences": {},
105
+ "roles": {},
106
+ "policies": {},
107
+ "views": {},
108
+ "_meta": {
109
+ "columns": {},
110
+ "schemas": {},
111
+ "tables": {}
112
+ }
113
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1781129403698,
9
+ "tag": "0000_late_alex_wilder",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@checkstack/status-page-backend",
3
+ "version": "0.1.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "checkstack": {
8
+ "type": "backend"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsgo -b",
12
+ "generate": "drizzle-kit generate",
13
+ "lint": "bun run lint:code",
14
+ "lint:code": "eslint . --max-warnings 0"
15
+ },
16
+ "dependencies": {
17
+ "@checkstack/backend-api": "0.23.0",
18
+ "@checkstack/common": "0.16.0",
19
+ "@checkstack/status-page-common": "0.1.0",
20
+ "drizzle-orm": "^0.45.0",
21
+ "zod": "^4.2.1",
22
+ "@orpc/server": "^1.14.4",
23
+ "@orpc/client": "^1.14.4"
24
+ },
25
+ "devDependencies": {
26
+ "@checkstack/drizzle-helper": "0.0.5",
27
+ "@checkstack/scripts": "0.6.2",
28
+ "@checkstack/test-utils-backend": "0.1.43",
29
+ "@checkstack/tsconfig": "0.0.7",
30
+ "@types/bun": "^1.0.0",
31
+ "drizzle-kit": "^0.31.10",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ pluginMetadata,
3
+ TextConfigSchema,
4
+ TextDtoSchema,
5
+ HeadingConfigSchema,
6
+ HeadingDtoSchema,
7
+ LinksConfigSchema,
8
+ LinksDtoSchema,
9
+ ImageConfigSchema,
10
+ ImageDtoSchema,
11
+ DividerConfigSchema,
12
+ DividerDtoSchema,
13
+ } from "@checkstack/status-page-common";
14
+ import type {
15
+ WidgetTypeDefinition,
16
+ WidgetTypeRegistry,
17
+ } from "./widget-registry";
18
+
19
+ /**
20
+ * The CONTENT widgets — the only built-ins the status-page platform owns,
21
+ * because they bind no domain resources (their config IS their public DTO).
22
+ * The domain widgets (system health, uptime, incidents, maintenance, ...) are
23
+ * contributed by their OWNING plugins via `statusWidgetTypeExtensionPoint`, so
24
+ * this package never depends on catalog/healthcheck/incident/maintenance.
25
+ */
26
+ function staticWidget({
27
+ id,
28
+ displayName,
29
+ description,
30
+ configSchema,
31
+ dtoSchema,
32
+ }: {
33
+ id: string;
34
+ displayName: string;
35
+ description: string;
36
+ configSchema: WidgetTypeDefinition["configSchema"];
37
+ dtoSchema: WidgetTypeDefinition["dtoSchema"];
38
+ }): WidgetTypeDefinition {
39
+ return {
40
+ id,
41
+ displayName,
42
+ description,
43
+ category: "Content",
44
+ binding: "none",
45
+ configSchema,
46
+ dtoSchema,
47
+ boundResources: () => [],
48
+ async resolvePublic({ config }) {
49
+ return dtoSchema.parse(configSchema.parse(config));
50
+ },
51
+ };
52
+ }
53
+
54
+ /** Register the content widgets the platform owns directly. */
55
+ export function registerContentWidgets(registry: WidgetTypeRegistry): void {
56
+ const widgets: WidgetTypeDefinition[] = [
57
+ staticWidget({
58
+ id: "text",
59
+ displayName: "Text",
60
+ description: "A Markdown text block (rendered sanitized).",
61
+ configSchema: TextConfigSchema,
62
+ dtoSchema: TextDtoSchema,
63
+ }),
64
+ staticWidget({
65
+ id: "heading",
66
+ displayName: "Heading",
67
+ description: "A section heading.",
68
+ configSchema: HeadingConfigSchema,
69
+ dtoSchema: HeadingDtoSchema,
70
+ }),
71
+ staticWidget({
72
+ id: "links",
73
+ displayName: "Links",
74
+ description: "A list of labelled links.",
75
+ configSchema: LinksConfigSchema,
76
+ dtoSchema: LinksDtoSchema,
77
+ }),
78
+ staticWidget({
79
+ id: "image",
80
+ displayName: "Image / logo",
81
+ description: "An image (e.g. your logo).",
82
+ configSchema: ImageConfigSchema,
83
+ dtoSchema: ImageDtoSchema,
84
+ }),
85
+ staticWidget({
86
+ id: "divider",
87
+ displayName: "Divider",
88
+ description: "A horizontal divider.",
89
+ configSchema: DividerConfigSchema,
90
+ dtoSchema: DividerDtoSchema,
91
+ }),
92
+ ];
93
+ for (const w of widgets) registry.register(w, pluginMetadata);
94
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Status-page hooks for cross-plugin audit. Publishing a page is a deliberate
5
+ * PUBLIC-EXPOSURE action; an audit-log plugin can subscribe to record exactly
6
+ * which resources a page exposed, when, and by whom.
7
+ */
8
+ export const statusPageHooks = {
9
+ published: createHook<{
10
+ pageId: string;
11
+ slug: string;
12
+ /** Qualified `type:id` of every resource the published widgets expose. */
13
+ exposedResources: string[];
14
+ publishedBy: string;
15
+ publishedAt: string;
16
+ }>("statuspage.page.published"),
17
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import { inArray, ilike } from "drizzle-orm";
4
+ import {
5
+ pluginMetadata,
6
+ statusPageContract,
7
+ statusPageAccessRules,
8
+ } from "@checkstack/status-page-common";
9
+ import * as schema from "./schema";
10
+ import { statusPages } from "./schema";
11
+ import { createStatusPageRouter } from "./router";
12
+ import { StatusPageService } from "./service";
13
+ import {
14
+ createWidgetTypeRegistry,
15
+ statusWidgetTypeExtensionPoint,
16
+ } from "./widget-registry";
17
+ import { registerContentWidgets } from "./content-widgets";
18
+
19
+ const STATUS_PAGE_RESOURCE_TYPE = "statuspage.page";
20
+
21
+ export default createBackendPlugin({
22
+ metadata: pluginMetadata,
23
+
24
+ register(env) {
25
+ env.registerAccessRules(statusPageAccessRules);
26
+
27
+ // The widget-type registry is created here (register phase) and seeded with
28
+ // the built-ins, then exposed so ANY plugin can contribute widget types via
29
+ // the extension point during its own register/init.
30
+ const registry = createWidgetTypeRegistry();
31
+ registerContentWidgets(registry);
32
+ env.registerExtensionPoint(statusWidgetTypeExtensionPoint, {
33
+ registerWidgetType: (definition, meta) =>
34
+ registry.register(definition, meta),
35
+ });
36
+
37
+ env.registerInit({
38
+ schema,
39
+ deps: {
40
+ rpc: coreServices.rpc,
41
+ logger: coreServices.logger,
42
+ rpcClient: coreServices.rpcClient,
43
+ resourceResolverRegistry: coreServices.resourceResolverRegistry,
44
+ },
45
+ init: async ({
46
+ database,
47
+ rpc,
48
+ logger,
49
+ rpcClient,
50
+ resourceResolverRegistry,
51
+ }) => {
52
+ const db = database as SafeDatabase<typeof schema>;
53
+
54
+ const service = new StatusPageService({
55
+ db,
56
+ registry,
57
+ rpcClient,
58
+ logger,
59
+ });
60
+ const internalUrl =
61
+ process.env.INTERNAL_URL || "http://localhost:3000";
62
+ const router = createStatusPageRouter({ service, internalUrl });
63
+ rpc.registerRouter(router, statusPageContract);
64
+
65
+ // Let the Teams admin resolve `statuspage.page` grants by name + search.
66
+ resourceResolverRegistry.register(STATUS_PAGE_RESOURCE_TYPE, {
67
+ resolveNames: async (ids) => {
68
+ if (ids.length === 0) return new Map();
69
+ const rows = await db
70
+ .select({ id: statusPages.id, title: statusPages.title })
71
+ .from(statusPages)
72
+ .where(inArray(statusPages.id, ids));
73
+ return new Map(rows.map((r) => [r.id, r.title]));
74
+ },
75
+ search: async (query, limit) => {
76
+ const rows = await db
77
+ .select({ id: statusPages.id, name: statusPages.title })
78
+ .from(statusPages)
79
+ .where(ilike(statusPages.title, `%${query}%`))
80
+ .limit(limit);
81
+ return rows;
82
+ },
83
+ });
84
+
85
+ logger.debug("✅ Status Pages backend initialized.");
86
+ },
87
+ });
88
+ },
89
+ });
90
+
91
+ export {
92
+ statusWidgetTypeExtensionPoint,
93
+ type StatusWidgetTypeExtensionPoint,
94
+ type WidgetTypeDefinition,
95
+ type WidgetResolveContext,
96
+ type BoundResource,
97
+ } from "./widget-registry";
98
+ export { statusPageHooks } from "./hooks";
package/src/router.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { implement } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ correlationMiddleware,
5
+ type RpcContext,
6
+ } from "@checkstack/backend-api";
7
+ import { statusPageContract } from "@checkstack/status-page-common";
8
+ import { statusPageHooks } from "./hooks";
9
+ import {
10
+ createUserScopedRpcClient,
11
+ forwardableAuthHeadersFrom,
12
+ } from "./user-client";
13
+ import type { StatusPageService } from "./service";
14
+
15
+ const os = implement(statusPageContract)
16
+ .$context<RpcContext>()
17
+ .use(correlationMiddleware)
18
+ .use(autoAuthMiddleware);
19
+
20
+ export interface StatusPageRouterDeps {
21
+ service: StatusPageService;
22
+ /** Loopback base URL for the user-scoped publish gate. */
23
+ internalUrl: string;
24
+ }
25
+
26
+ export function createStatusPageRouter({
27
+ service,
28
+ internalUrl,
29
+ }: StatusPageRouterDeps) {
30
+ const listStatusPages = os.listStatusPages.handler(async () => ({
31
+ pages: await service.list(),
32
+ }));
33
+
34
+ const getStatusPage = os.getStatusPage.handler(async ({ input }) =>
35
+ service.get(input.id),
36
+ );
37
+
38
+ const createStatusPage = os.createStatusPage.handler(async ({ input }) => {
39
+ // `teamId` is consumed by the create-mode middleware (it resolves + writes
40
+ // the owning-team grant); the table has no team column, so strip it here.
41
+ const { teamId: _teamId, ...rest } = input;
42
+ return service.create(rest);
43
+ });
44
+
45
+ const updateStatusPage = os.updateStatusPage.handler(async ({ input }) =>
46
+ service.update(input),
47
+ );
48
+
49
+ const publishStatusPage = os.publishStatusPage.handler(
50
+ async ({ input, context }) => {
51
+ const userClient = createUserScopedRpcClient({
52
+ internalUrl,
53
+ forwardHeaders: forwardableAuthHeadersFrom(context.requestHeaders),
54
+ });
55
+ const { page, exposed } = await service.publish({
56
+ id: input.id,
57
+ userClient,
58
+ });
59
+ const publishedBy = context.user
60
+ ? context.user.type === "service"
61
+ ? `service:${context.user.pluginId}`
62
+ : context.user.id
63
+ : "system";
64
+ await context.emitHook(statusPageHooks.published, {
65
+ pageId: page.id,
66
+ slug: page.slug,
67
+ exposedResources: exposed.map((b) => `${b.resourceType}:${b.resourceId}`),
68
+ publishedBy,
69
+ publishedAt: page.publishedAt ?? new Date().toISOString(),
70
+ });
71
+ return page;
72
+ },
73
+ );
74
+
75
+ const unpublishStatusPage = os.unpublishStatusPage.handler(
76
+ async ({ input }) => service.unpublish(input.id),
77
+ );
78
+
79
+ const deleteStatusPage = os.deleteStatusPage.handler(async ({ input }) => ({
80
+ deleted: await service.remove(input.id),
81
+ }));
82
+
83
+ const listWidgetTypes = os.listWidgetTypes.handler(async () => ({
84
+ widgetTypes: service.listWidgetTypes(),
85
+ }));
86
+
87
+ const getPublishedStatusPage = os.getPublishedStatusPage.handler(
88
+ async ({ input, context }) => {
89
+ const isAuthenticated =
90
+ context.user?.type === "user" || context.user?.type === "application";
91
+ return service.resolvePublished({ slug: input.slug, isAuthenticated });
92
+ },
93
+ );
94
+
95
+ return {
96
+ listStatusPages,
97
+ getStatusPage,
98
+ createStatusPage,
99
+ updateStatusPage,
100
+ publishStatusPage,
101
+ unpublishStatusPage,
102
+ deleteStatusPage,
103
+ listWidgetTypes,
104
+ getPublishedStatusPage,
105
+ };
106
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,48 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ timestamp,
5
+ jsonb,
6
+ uniqueIndex,
7
+ } from "drizzle-orm/pg-core";
8
+ import type { InferSelectModel } from "drizzle-orm";
9
+ import type {
10
+ StatusPageLayout,
11
+ StatusPageTheme,
12
+ } from "@checkstack/status-page-common";
13
+
14
+ /**
15
+ * Status pages. Team ownership lives in the relation-tuple store keyed by
16
+ * `statuspage.page` / id (NOT a column here) — exactly like systems/automations.
17
+ *
18
+ * The DRAFT layout is the builder's working copy. PUBLISH snapshots it into
19
+ * `published_layout`; the public resolver reads ONLY `published_layout`, so a
20
+ * page being edited never changes under visitors until republished.
21
+ */
22
+ export const statusPages = pgTable(
23
+ "status_pages",
24
+ {
25
+ id: text("id")
26
+ .primaryKey()
27
+ .$defaultFn(() => crypto.randomUUID()),
28
+ slug: text("slug").notNull(),
29
+ title: text("title").notNull(),
30
+ /** "public" | "authenticated" */
31
+ visibility: text("visibility").notNull().default("public"),
32
+ theme: jsonb("theme").$type<StatusPageTheme>().notNull().default({ mode: "auto" }),
33
+ draftLayout: jsonb("draft_layout")
34
+ .$type<StatusPageLayout>()
35
+ .notNull()
36
+ .default([]),
37
+ /** Null until the page is published; the public-facing snapshot. */
38
+ publishedLayout: jsonb("published_layout").$type<StatusPageLayout>(),
39
+ publishedAt: timestamp("published_at"),
40
+ createdAt: timestamp("created_at").defaultNow().notNull(),
41
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
42
+ },
43
+ (t) => ({
44
+ slugUnique: uniqueIndex("status_pages_slug_unique").on(t.slug),
45
+ }),
46
+ );
47
+
48
+ export type StatusPageRow = InferSelectModel<typeof statusPages>;