@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 +62 -0
- package/drizzle/0000_late_alex_wilder.sql +14 -0
- package/drizzle/meta/0000_snapshot.json +113 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/content-widgets.ts +94 -0
- package/src/hooks.ts +17 -0
- package/src/index.ts +98 -0
- package/src/router.ts +106 -0
- package/src/schema.ts +48 -0
- package/src/service.test.ts +276 -0
- package/src/service.ts +337 -0
- package/src/user-client.ts +39 -0
- package/src/widget-registry.ts +120 -0
- package/tsconfig.json +23 -0
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
|
+
}
|
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>;
|