Plan: GetPageComponent β resolve a page component by ID #
Motivation #
Customer (Philipp Honsel, netgo) is on the new /rpc Connect API (C#
backend, plain HTTP + JSON, no SDK). Their concrete pain:
API Gap β Component Name Resolution. They fetch the entire status-page content daily just to build a component-
idβnamemap, because there is no way to resolve a single component by id. Confirmed as a gap.
Today the only read path for a PageComponent is
StatusPageService/GetStatusPageContent, digging the component out of the
.components array. Add a direct single-component read.
The customer imagines a RESTful GET /status-page-component/{id}; the actual
call on this API is a Connect unary POST (they already call
.../GetStatusPageContent the same way):
POST https://api.openstatus.dev/rpc/openstatus.status_page.v1.StatusPageService/GetPageComponent
x-openstatus-key: <key>
content-type: application/json
{ "id": "1777074467622" }
Response β the existing PageComponent shape (includes name, which is what
they need, plus everything else):
{
"component": {
"id": "1777074467622",
"pageId": "5076",
"name": "system",
"type": "PAGE_COMPONENT_TYPE_MONITOR",
"monitorId": "10360",
"order": 0,
"groupId": "",
"groupOrder": 0,
"description": "",
"createdAt": "2026-06-29T11:35:21.000Z",
"updatedAt": "2026-07-01T09:36:06.000Z"
}
}
Decisions (settled) #
| Aspect | Decision | Rationale |
|---|---|---|
| Access model | Auth-only, workspace-scoped, request is { id }. Cross-workspace id β 404. No public slug path. |
Matches GetStatusPage/GetMonitor; customer calls with an API key. |
| Response | Bare PageComponent, existing message untouched. No page_component.proto edit. |
Already carries name + everything; smallest change. |
| RPC name | GetPageComponent (not GetPageComponentById). |
House convention: GetMonitor, GetStatusPage, GetMaintenance, GetNotification, GetStatusReport β none use a ById suffix. Tell the customer the final path. |
| Implementation | Inline handler, reuse existing getComponentById + dbComponentToProto + pageComponentNotFoundError. No new service files. |
This handler already reads components inline (e.g. updateComponent). Read β no audit, no requireScope. |
| Malformed id | Non-numeric β NaN β lookup miss β 404 (fall-through). Empty string β 400 from the min_len=1 validator. |
Identical to every sibling Get*; no bespoke validation branch. |
| Tests | Full 5-case mirror of the GetStatusPage block. |
Consistency; reuses existing seed + connectRequest helper. |
| Transports | Connect/REST /rpc API only. No MCP tool, no tRPC procedure. |
Customer need is API-only; MCP already has listPageComponentsTool; dashboard reads via its own tRPC. |
Non-breaking: additive RPC + messages, passes buf FILE breaking rule.
1. Proto β api/openstatus/status_page/v1/service.proto #
No new message types (PageComponent in page_component.proto is reused).
Add the RPC in the Component Management block of StatusPageService, after
UpdateComponent:
// GetPageComponent retrieves a single component by ID.
rpc GetPageComponent(GetPageComponentRequest) returns (GetPageComponentResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (gnostic.openapi.v3.operation) = {
description: "Returns a single status-page component by its ID, scoped to the authenticated workspace. Use this to resolve a component id to its name (and other fields) instead of fetching the whole page via GetStatusPageContent and filtering .components."
};
}
Add the messages in the Component Management Messages section:
// GetPageComponentRequest is the request to fetch a single component by ID.
message GetPageComponentRequest {
// ID of the component to retrieve (required).
string id = 1 [(buf.validate.field).string.min_len = 1];
}
// GetPageComponentResponse is the response containing the component.
message GetPageComponentResponse {
// The requested component.
PageComponent component = 1;
}
2. Regenerate + sync OpenAPI #
From packages/proto:
pnpm buf:lint # STANDARD lint must pass
pnpm buf:ts # regenerate gen/ts (server consumes this)
pnpm buf:openapi # refresh gen/openapi.yaml (NO_SIDE_EFFECTS β allow-get)
Then the required manual sync (server serves the static copy via Scalar at
/openapi; there is no automated copy script β static/openapi.yaml was last
updated by hand alongside the GetStatusPageOverview PR #2308):
cp packages/proto/gen/openapi.yaml apps/server/static/openapi.yaml
Commit the regenerated gen/ts/**, gen/openapi.yaml, and the updated
apps/server/static/openapi.yaml together.
3. Handler β apps/server/src/routes/rpc/handlers/status-page/index.ts #
All dependencies already exist in this file: getComponentById(id, workspaceId)
(~line 277), dbComponentToProto (imported), pageComponentNotFoundError
(imported, resolves to Code.NotFound β HTTP 404).
Add to statusPageServiceImpl, in the Component Management block:
async getPageComponent(req, ctx) {
try {
const rpcCtx = getRpcContext(ctx);
const id = req.id?.trim();
if (!id) throw pageComponentNotFoundError(req.id);
const component = await getComponentById(Number(id), rpcCtx.workspace.id);
if (!component) throw pageComponentNotFoundError(id);
return { component: dbComponentToProto(component) };
} catch (err) {
toConnectError(err);
}
},
getComponentById filters by workspaceId, so a foreign-workspace id returns
404 with no info leak. The generated ServiceImpl<typeof StatusPageService>
type now requires this method β a missing impl fails the build.
4. Tests β apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts #
New describe("StatusPageService.GetPageComponent") block mirroring
GetStatusPage, using the existing connectRequest(method, body, headers)
helper and the already-seeded ${TEST_PREFIX}-component (workspace 1):
| Test | Input | Assert |
|---|---|---|
| returns component by ID | { id: <seeded component id> }, key "1" |
200; body component.name/id/pageId/type/monitorId correct |
| 401 when no auth key | { id: ... }, no key |
401 |
| 404 for non-existent component | { id: "99999" }, key "1" |
404 |
| error when ID is empty | { id: "" }, key "1" |
400 (min_len validator) |
| 404 for component in different workspace | insert component in workspace 2, key "1" |
404 (cleanup in finally) |
5. Verification #
pnpm buf:lint && pnpm buf:ts && pnpm buf:openapi+ thecpsync.- Typecheck
apps/server(theServiceImpltype enforces the new method). - Run the status-page RPC test suite (5 new cases green).
- Manual smoke:
curl -X POST https://<host>/rpc/openstatus.status_page.v1.StatusPageService/GetPageComponent \ -H "x-openstatus-key: <key>" -H "content-type: application/json" \ -d '{"id":"<componentId>"}' - Reply to the customer with the exact POST call above (their
GET /path/{id}idea maps to this/rpcPOST they already use forGetStatusPageContent).
Files touched #
| File | Change |
|---|---|
packages/proto/api/openstatus/status_page/v1/service.proto |
+1 RPC, +2 messages |
packages/proto/gen/ts/**, packages/proto/gen/openapi.yaml |
regenerated |
apps/server/static/openapi.yaml |
synced copy of gen/openapi.yaml |
apps/server/src/routes/rpc/handlers/status-page/index.ts |
+1 handler method |
apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts |
+1 describe block, 5 tests |
Explicitly out of scope #
- No
page_component.proto/ message changes (bare component is enough). - No enrichment (monitor status, group name, computed status).
- No new service verb (inline handler reuse).
- No MCP tool, no tRPC procedure, no legacy v1 REST alias.
- No public slug access path.
Implementation Checklist #
Ordered by phase. Each phase gates the next (proto must lint before codegen; codegen must land before the handler typechecks). Checkboxes are individual, verifiable tasks.