@checkstack/ai-backend 0.1.2 → 0.1.4
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 +5 -5
- package/src/agent-runner.test.ts +50 -0
- package/src/agent-runner.ts +13 -3
- package/src/chat/chat-handler.ts +6 -0
- package/src/chat/chat-service.ts +13 -18
- package/src/chat/classifier.logic.test.ts +11 -0
- package/src/chat/classifier.logic.ts +16 -9
- package/src/chat/model-schema.test.ts +264 -0
- package/src/chat/model-schema.ts +334 -0
- package/src/chat/sdk-tools.ts +32 -35
- package/src/chat/system-prompt.test.ts +113 -0
- package/src/chat/system-prompt.ts +146 -0
- package/src/generated/docs-index.ts +10 -8
- package/src/serializer.test.ts +22 -0
|
@@ -82,10 +82,11 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
82
82
|
"Per-integration model selection",
|
|
83
83
|
"Off-topic guard",
|
|
84
84
|
"Per-integration LLM spend cap",
|
|
85
|
+
"Dates and timezones",
|
|
85
86
|
"No secret leaves the backend",
|
|
86
87
|
"Related"
|
|
87
88
|
],
|
|
88
|
-
"content": "The in-app AI assistant is a server-side agent loop built on the Vercel AI SDK. It runs entirely on the backend, uses the same tool registry as the MCP server, persists conversations in shared Postgres, and never lets the model silently change state. Read tools auto-run; mutating and destructive tools surface a confirm card that a human must approve.\n\n## The agent loop runs on the backend\n\nThe chat turn is a raw HTTP handler at `/api/ai/chat` (server-sent events, because streaming needs a raw handler). The handler authenticates the request through the platform auth strategy, requires a logged-in real user (applications and services use MCP, not chat), and hands the turn to the agent loop. The model provider is created on the backend from the selected integration's credentials, so the API key never crosses to the browser. The browser only ever receives streamed tokens and tool events.\n\n```ts\n// Provider-agnostic via base-URL override (OpenAI, Azure, OpenRouter, Ollama, ...).\nconst model = buildLanguageModel({ connection, model: conversation.model });\nconst result = streamText({ model, system, messages, tools, stopWhen: stepCountIs(8) });\nreturn result.toUIMessageStreamResponse();\n```\n\n## Tools come from the same registry\n\nThe loop offers the model exactly the tools the resolver allows for the logged-in principal, no more. The model is treated as an untrusted caller: it picks arguments, but it can never reach a tool the principal cannot use, and every tool call is gated server-side.\n\n- `read` tools auto-run. Their execution re-enters the live router as the logged-in user (the chat request's own auth is forwarded), so handler-side authorization runs exactly as for any other caller. Each successful read writes an `ai_tool_calls` row with `transport: \"chat\"` (and an args hash, never the raw args), so chat reads appear in the audit log AND count toward the per-principal rate-limit budget, exactly like MCP reads.\n- `mutate` and `destructive` tools never run inline. Their executor runs the `propose` dry-run and returns a confirm card carrying the single-use proposal token and the validated payload. Nothing is committed until the operator clicks Apply, which calls `applyTool` with the token.\n\n```ts\n// Disposition of a model-requested tool (the single server-side gate):\ndisposeAgentTool({ toolName, principal, resolver, getTool });\n// -> { kind: \"run\" } | { kind: \"confirm\" } | { kind: \"refused\" }\n```\n\nThe same per-principal rate-limit budget that protects MCP is enforced before every tool call in the loop. See [Propose and apply](/checkstack/developer-guide/ai/propose-apply/) for the budget and the confirm-card token lifecycle.\n\n## The model acknowledges a confirm-card decision\n\nA confirm card ends the model's turn: the model has said what it will do and is now waiting on the operator. When the operator clicks Apply (or Decline), the actual apply still runs through the unchanged `applyTool` propose/apply path, and then a short follow-up turn makes the model react so the conversation does not dead-end on \"waiting for your confirmation\".\n\nThat follow-up is a second mode of the same `/api/ai/chat` handler. Instead of a `message`, the POST body carries a `decision` (the proposal token plus `apply` or `decline`); the handler routes it to `streamDecision`, which streams the model's acknowledgment over the same SSE path as a normal turn.\n\n```ts\n// POST /api/ai/chat — a normal turn OR a confirm-card decision turn:\n{ conversationId, connectionId, model?, message: \"...\" } // user turn\n{ conversationId, connectionId, model?, decision: { token, kind } } // apply | decline\n```\n\nThe decision note handed to the model is derived SERVER-SIDE from the stored proposal (its tool name and the one-line summary captured at propose time), so no client-supplied text ever reaches the model. The note is EPHEMERAL: it is appended to that turn's history only and never persisted; the assistant's streamed reply is what gets saved, and it carries the outcome forward so later turns know the change is live. `streamDecision` re-checks ownership, that the proposal belongs to THIS conversation, and — for an `apply` — that the proposal is actually in the `applied` state (the apply ran first), refusing with a 409 otherwise so the model can never falsely claim a change took effect.\n\n```ts\nproposeApply.describeProposal({ token }); // read-only: tool name, summary, status, conversation (no consume)\nbuildDecisionNote({ decision, toolName, summary }); // the ephemeral, server-derived note\n```\n\n## Conversations are durable and pod-independent\n\nConversations and messages live in `ai_conversations` and `ai_messages` in shared Postgres. The user message is persisted before streaming begins, and the assistant message on completion, so a mid-stream pod restart still leaves a complete, resumable transcript. Any pod can list, open, or continue a chat; nothing about a conversation is pod-local. All reads are owner-scoped, so a user can only ever see their own conversations.\n\nWhen a turn is resumed, the model must see its prior TOOL interactions, not just the text it eventually said. On completion the loop persists the canonical AI-SDK `ResponseMessage[]` for the turn (assistant tool-call parts plus tool-result parts) into the additive `ai_messages.model_messages` column. On the next turn `toModelMessages` replays those messages verbatim, so a multi-turn conversation reconstructs the full tool-call history for the model. Rows written before this column existed (or plain user/system rows) fall back to text-only replay. Because the replay history lives in shared Postgres, replay is identical on whichever pod handles the next turn.\n\nThe conversation contract (RPC):\n\n```ts\nai.listChatIntegrations(); // selectable providers + model UX (no secrets)\nai.listConversations(); // the user's conversations, newest first\nai.createConversation({ integrationId, model });\nai.getConversation({ id }); // conversation + full transcript\nai.updateConversation({ id, title, model });\nai.archiveConversation({ id }); // soft delete: the user-facing \"Delete\" action\nai.deleteConversation({ id }); // hard delete (cascades); not the user action\n```\n\n## Deleting a chat is a soft archive\n\nThe user-facing \"Delete\" action in the sidebar does NOT remove the row. It calls `archiveConversation`, which stamps an `archived_at` timestamp on the `ai_conversations` row. `listConversations` filters `archived_at IS NULL`, so an archived chat disappears from the sidebar, but the conversation and its messages are RETAINED in Postgres for later abuse introspection. The archive is owner-scoped, so a user can only archive their own chats, and a repeat archive of an already-archived row is a no-op.\n\nThe frontend confirms the action through a modal (labeled \"Delete\"), then archives, lets the owning plugin's query invalidation refresh the list, and clears the view back to the empty state if the archived chat was the open one. The hard `deleteConversation` method is retained for non-user callers (such as a retention sweep) but is never wired to the sidebar, so nothing the user clicks ever hard-deletes a transcript.\n\n```ts\narchiveConversation({ id, userId }); // stamps archived_at = now, owner-scoped\n// listConversations filters archived_at IS NULL\n```\n\n## Starting a new chat\n\nThe \"New chat\" button creates a fresh conversation, makes it the active (highlighted) one, and clears the message view. Because `createConversation` is an oRPC mutation, it auto-invalidates the plugin's conversation list on success, so the new chat appears in the sidebar immediately. To avoid spawning a pile of empty \"Untitled chat\" rows, the click is deduplicated: if the conversation already open is itself an empty untitled draft (no title and no messages), the button reuses it instead of creating another row. The decision is a pure helper so it is unit-testable without rendering the page.\n\n```ts\ndecideNewChatAction({ current, messages }); // -> { kind: \"reuse\" } | { kind: \"create\" }\n```\n\n## Conversations are auto-titled\n\nA new conversation starts untitled, so the sidebar would otherwise show \"Untitled chat\". After the first user message of a still-untitled conversation, the backend derives a concise title (at most six words, no quotes, markdown, or trailing punctuation) and persists it with `updateConversation({ title })`. The title is produced by a cheap `generateText` call that reuses the turn's resolved connection and model, then sanitized with `sanitizeGeneratedTitle`.\n\nTitling is fire-and-forget: it runs detached from the streamed turn, so it never delays or crashes the response. On any model or sanitize failure it falls back to a deterministic heuristic from the first message (`deriveHeuristicTitle`: collapse whitespace, first six words, capped at sixty characters). The title lives in the shared `ai_conversations` table, so it is readable on every pod. The chat page invalidates the conversation list when a turn completes to pick up the new title, because the streaming turn is a raw SSE fetch rather than an oRPC mutation and so does not auto-invalidate.\n\n```ts\nderiveHeuristicTitle(firstMessage); // fallback when the model errors\nsanitizeGeneratedTitle(raw); // strip quotes/markdown/punctuation, cap length\n```\n\n## Per-integration model selection\n\nModel choice is a property of the credential and provider, so it lives on the OpenAI-compatible integration connection: `defaultModel` is required and `availableModels` is an optional allowlist. The chat model picker always renders a `Select` whose options are `[defaultModel, ...availableModels]`, de-duplicated with the default first, so the connection's own default is always selectable. With no `availableModels` the picker contains just the default; it is never a free-text field. The model id is untrusted wire input, so it is revalidated server-side at two points: `createConversation` / `updateConversation` coerce a stored model against the integration's allowlist, and `buildLanguageModel` always runs the requested (or stored) model id through `resolveModelId` before handing it to the provider, so an out-of-allowlist id is coerced to `defaultModel` and never reaches the provider. An empty allowlist allows any model from the picker's default (free-text providers like Ollama still configure a `defaultModel`).\n\n```ts\nresolveModelId({ connection, requested }); // requested, or defaultModel if out of allowlist\n```\n\n## Off-topic guard\n\nThe assistant helps with operating Checkstack (incidents, health checks, anomalies, automations, monitoring, and on-call) AND with questions about the assistant itself or how to use Checkstack. Two layers keep clearly unrelated requests (general coding help, creative writing, general trivia) from spending tokens on the expensive tool loop.\n\nFirst, the system prompt instructs the assistant to decline clearly unrelated requests with a one-line redirect, so even a request that slips past the classifier is steered back.\n\nSecond, a cheap topical pre-classifier runs BEFORE the agent/tool loop. It is a small `generateText` call (injectable like the title generator, defaulting to the turn's resolved model) with a tight prompt that returns a single token: `ON_TOPIC` or `OFF_TOPIC`. The following are always `ON_TOPIC`:\n\n- Checkstack operations: incidents, health checks, anomalies, automations, monitoring, on-call, the platform's data and configuration.\n- Meta/capability questions about the assistant itself: \"what can you do?\", \"who are you?\", \"help\", \"what features do you have?\".\n- Greetings and conversational openers: \"hi\", \"hello\", \"hey\".\n- How-to and conceptual questions about using Checkstack features or workflows: \"how do health checks work?\", \"how do I create an automation?\".\n\nOnly CLEARLY unrelated requests are `OFF_TOPIC`: general coding help unrelated to Checkstack, creative writing, and general trivia or knowledge questions.\n\nThe reply is parsed by a pure function that leans toward `ON_TOPIC` on anything ambiguous or unrecognized, because a false refusal of a real ops question is worse than letting one off-topic request slide.\n\n- On `OFF_TOPIC` the turn short-circuits: the expensive tool loop never runs. A canned, concise refusal is streamed back over the same SSE path the normal turn uses (so the frontend renders it identically) and persisted as the assistant message. The refusal nudges the user toward supported topics rather than just declining.\n- The classifier is fail-open: if the classifier model call throws, the turn proceeds normally. A classifier hiccup must never block legitimate use.\n- The classifier's own small token usage is recorded against the shared `ai_spend` ledger, exactly like any other model call, so it is accounted toward the spend cap.\n\n```ts\nbuildClassifierPrompt({ userText }); // { system, prompt } for the cheap call\nparseClassifierVerdict(raw); // \"ON_TOPIC\" | \"OFF_TOPIC\" (ambiguous -> ON_TOPIC)\n```\n\n## Per-integration LLM spend cap\n\nEach OpenAI-compatible connection may carry an optional `spendCap`. It is OFF by default: no cap is enforced unless you configure one in the connection's settings form. The cap is a token-count budget, not a USD budget, because token counts are deterministic and provider-agnostic. Every OpenAI-compatible provider (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio) reports token usage through the AI SDK, but only some publish a price table and self-hosted models have none, so a USD cap would need a per-model pricing table that drifts and is meaningless for local models.\n\n```ts\nspendCap: { tokenBudget: 200000, windowMinutes: 60 } // optional; omit for no cap\n```\n\nWhen a cap is set, the loop refuses a new turn once the principal's token usage against that integration in the trailing `windowMinutes` reaches `tokenBudget`, returning a clear spend-exceeded error (HTTP 429). Spend is a rolling-window SUM over the shared `ai_spend` ledger: every completed turn appends one row with the AI SDK's reported input and output tokens, keyed by integration and principal. Because the sum is read from the same shared table every pod writes to, the cap holds across all pods, exactly like the per-principal tool rate-limit budget. An in-memory per-pod token counter would let N pods each allow the cap, which a single-process test could never catch, so the ledger is durable Postgres and the cross-pod count is verified in `core/ai-backend/src/rate-limit/spend-ledger.it.test.ts`.\n\n## No secret leaves the backend\n\nThe integration API key is stored in the Secrets Vault and read only on the backend when building the model provider. The chat RPCs expose only non-secret model UX metadata (`listChatIntegrations` returns connection id, name, default model, and the allowlist). The streamed response carries tokens, tool calls, and tool results (already redacted by their source procedures), never the credential. The no-secret-leak guarantee is regression-guarded across every AI DTO in `core/ai-backend/src/hardening/no-secret-leak.test.ts`.\n\nThe free-form `ai_messages.content` and `model_messages` bags are an exception that could, in principle, carry a credential if a buggy or malicious tool result smuggled one in. That guarantee is no longer merely architectural: `appendMessage` runs `scrubContent` on every message write, redacting any credential-shaped key (`apiKey`, `authorization`, `password`, `x-secret`, and similar) and any high-confidence credential value (an `sk-...` key, a `Bearer` token) before the row reaches Postgres. The scrub is conservative, so ordinary chat prose that merely mentions the word \"token\" or \"password\" is preserved; only credentials are stripped. The canary regression test injects a secret into message content and asserts it is stripped on write in `core/ai-backend/src/chat/scrub-content.test.ts` and `core/ai-backend/src/hardening/no-secret-leak.test.ts`.\n\n## Related\n\nChat shares the [tool registry](/checkstack/developer-guide/ai/tool-registry/) and resolver with the [MCP server](/checkstack/developer-guide/ai/mcp-server/), and gates mutating tools through [propose and apply](/checkstack/developer-guide/ai/propose-apply/). A model that picks a tool the principal cannot use is refused server-side (guarded in `core/ai-backend/src/chat/agent-loop.test.ts` and `core/ai-backend/src/hardening/handler-authz.test.ts`), and cross-pod conversation readback is verified in `core/ai-backend/src/chat/conversation-store.it.test.ts`. See the [AI platform overview](/checkstack/developer-guide/ai/) for the full security model.",
|
|
89
|
+
"content": "The in-app AI assistant is a server-side agent loop built on the Vercel AI SDK. It runs entirely on the backend, uses the same tool registry as the MCP server, persists conversations in shared Postgres, and never lets the model silently change state. Read tools auto-run; mutating and destructive tools surface a confirm card that a human must approve.\n\n## The agent loop runs on the backend\n\nThe chat turn is a raw HTTP handler at `/api/ai/chat` (server-sent events, because streaming needs a raw handler). The handler authenticates the request through the platform auth strategy, requires a logged-in real user (applications and services use MCP, not chat), and hands the turn to the agent loop. The model provider is created on the backend from the selected integration's credentials, so the API key never crosses to the browser. The browser only ever receives streamed tokens and tool events.\n\n```ts\n// Provider-agnostic via base-URL override (OpenAI, Azure, OpenRouter, Ollama, ...).\nconst model = buildLanguageModel({ connection, model: conversation.model });\nconst result = streamText({ model, system, messages, tools, stopWhen: stepCountIs(8) });\nreturn result.toUIMessageStreamResponse();\n```\n\n## Tools come from the same registry\n\nThe loop offers the model exactly the tools the resolver allows for the logged-in principal, no more. The model is treated as an untrusted caller: it picks arguments, but it can never reach a tool the principal cannot use, and every tool call is gated server-side.\n\n- `read` tools auto-run. Their execution re-enters the live router as the logged-in user (the chat request's own auth is forwarded), so handler-side authorization runs exactly as for any other caller. Each successful read writes an `ai_tool_calls` row with `transport: \"chat\"` (and an args hash, never the raw args), so chat reads appear in the audit log AND count toward the per-principal rate-limit budget, exactly like MCP reads.\n- `mutate` and `destructive` tools never run inline. Their executor runs the `propose` dry-run and returns a confirm card carrying the single-use proposal token and the validated payload. Nothing is committed until the operator clicks Apply, which calls `applyTool` with the token.\n\n```ts\n// Disposition of a model-requested tool (the single server-side gate):\ndisposeAgentTool({ toolName, principal, resolver, getTool });\n// -> { kind: \"run\" } | { kind: \"confirm\" } | { kind: \"refused\" }\n```\n\nThe same per-principal rate-limit budget that protects MCP is enforced before every tool call in the loop. See [Propose and apply](/checkstack/developer-guide/ai/propose-apply/) for the budget and the confirm-card token lifecycle.\n\n## The model acknowledges a confirm-card decision\n\nA confirm card ends the model's turn: the model has said what it will do and is now waiting on the operator. When the operator clicks Apply (or Decline), the actual apply still runs through the unchanged `applyTool` propose/apply path, and then a short follow-up turn makes the model react so the conversation does not dead-end on \"waiting for your confirmation\".\n\nThat follow-up is a second mode of the same `/api/ai/chat` handler. Instead of a `message`, the POST body carries a `decision` (the proposal token plus `apply` or `decline`); the handler routes it to `streamDecision`, which streams the model's acknowledgment over the same SSE path as a normal turn.\n\n```ts\n// POST /api/ai/chat — a normal turn OR a confirm-card decision turn:\n{ conversationId, connectionId, model?, message: \"...\" } // user turn\n{ conversationId, connectionId, model?, decision: { token, kind } } // apply | decline\n```\n\nThe decision note handed to the model is derived SERVER-SIDE from the stored proposal (its tool name and the one-line summary captured at propose time), so no client-supplied text ever reaches the model. The note is EPHEMERAL: it is appended to that turn's history only and never persisted; the assistant's streamed reply is what gets saved, and it carries the outcome forward so later turns know the change is live. `streamDecision` re-checks ownership, that the proposal belongs to THIS conversation, and — for an `apply` — that the proposal is actually in the `applied` state (the apply ran first), refusing with a 409 otherwise so the model can never falsely claim a change took effect.\n\n```ts\nproposeApply.describeProposal({ token }); // read-only: tool name, summary, status, conversation (no consume)\nbuildDecisionNote({ decision, toolName, summary }); // the ephemeral, server-derived note\n```\n\n## Conversations are durable and pod-independent\n\nConversations and messages live in `ai_conversations` and `ai_messages` in shared Postgres. The user message is persisted before streaming begins, and the assistant message on completion, so a mid-stream pod restart still leaves a complete, resumable transcript. Any pod can list, open, or continue a chat; nothing about a conversation is pod-local. All reads are owner-scoped, so a user can only ever see their own conversations.\n\nWhen a turn is resumed, the model must see its prior TOOL interactions, not just the text it eventually said. On completion the loop persists the canonical AI-SDK `ResponseMessage[]` for the turn (assistant tool-call parts plus tool-result parts) into the additive `ai_messages.model_messages` column. On the next turn `toModelMessages` replays those messages verbatim, so a multi-turn conversation reconstructs the full tool-call history for the model. Rows written before this column existed (or plain user/system rows) fall back to text-only replay. Because the replay history lives in shared Postgres, replay is identical on whichever pod handles the next turn.\n\nThe conversation contract (RPC):\n\n```ts\nai.listChatIntegrations(); // selectable providers + model UX (no secrets)\nai.listConversations(); // the user's conversations, newest first\nai.createConversation({ integrationId, model });\nai.getConversation({ id }); // conversation + full transcript\nai.updateConversation({ id, title, model });\nai.archiveConversation({ id }); // soft delete: the user-facing \"Delete\" action\nai.deleteConversation({ id }); // hard delete (cascades); not the user action\n```\n\n## Deleting a chat is a soft archive\n\nThe user-facing \"Delete\" action in the sidebar does NOT remove the row. It calls `archiveConversation`, which stamps an `archived_at` timestamp on the `ai_conversations` row. `listConversations` filters `archived_at IS NULL`, so an archived chat disappears from the sidebar, but the conversation and its messages are RETAINED in Postgres for later abuse introspection. The archive is owner-scoped, so a user can only archive their own chats, and a repeat archive of an already-archived row is a no-op.\n\nThe frontend confirms the action through a modal (labeled \"Delete\"), then archives, lets the owning plugin's query invalidation refresh the list, and clears the view back to the empty state if the archived chat was the open one. The hard `deleteConversation` method is retained for non-user callers (such as a retention sweep) but is never wired to the sidebar, so nothing the user clicks ever hard-deletes a transcript.\n\n```ts\narchiveConversation({ id, userId }); // stamps archived_at = now, owner-scoped\n// listConversations filters archived_at IS NULL\n```\n\n## Starting a new chat\n\nThe \"New chat\" button creates a fresh conversation, makes it the active (highlighted) one, and clears the message view. Because `createConversation` is an oRPC mutation, it auto-invalidates the plugin's conversation list on success, so the new chat appears in the sidebar immediately. To avoid spawning a pile of empty \"Untitled chat\" rows, the click is deduplicated: if the conversation already open is itself an empty untitled draft (no title and no messages), the button reuses it instead of creating another row. The decision is a pure helper so it is unit-testable without rendering the page.\n\n```ts\ndecideNewChatAction({ current, messages }); // -> { kind: \"reuse\" } | { kind: \"create\" }\n```\n\n## Conversations are auto-titled\n\nA new conversation starts untitled, so the sidebar would otherwise show \"Untitled chat\". After the first user message of a still-untitled conversation, the backend derives a concise title (at most six words, no quotes, markdown, or trailing punctuation) and persists it with `updateConversation({ title })`. The title is produced by a cheap `generateText` call that reuses the turn's resolved connection and model, then sanitized with `sanitizeGeneratedTitle`.\n\nTitling is fire-and-forget: it runs detached from the streamed turn, so it never delays or crashes the response. On any model or sanitize failure it falls back to a deterministic heuristic from the first message (`deriveHeuristicTitle`: collapse whitespace, first six words, capped at sixty characters). The title lives in the shared `ai_conversations` table, so it is readable on every pod. The chat page invalidates the conversation list when a turn completes to pick up the new title, because the streaming turn is a raw SSE fetch rather than an oRPC mutation and so does not auto-invalidate.\n\n```ts\nderiveHeuristicTitle(firstMessage); // fallback when the model errors\nsanitizeGeneratedTitle(raw); // strip quotes/markdown/punctuation, cap length\n```\n\n## Per-integration model selection\n\nModel choice is a property of the credential and provider, so it lives on the OpenAI-compatible integration connection: `defaultModel` is required and `availableModels` is an optional allowlist. The chat model picker always renders a `Select` whose options are `[defaultModel, ...availableModels]`, de-duplicated with the default first, so the connection's own default is always selectable. With no `availableModels` the picker contains just the default; it is never a free-text field. The model id is untrusted wire input, so it is revalidated server-side at two points: `createConversation` / `updateConversation` coerce a stored model against the integration's allowlist, and `buildLanguageModel` always runs the requested (or stored) model id through `resolveModelId` before handing it to the provider, so an out-of-allowlist id is coerced to `defaultModel` and never reaches the provider. An empty allowlist allows any model from the picker's default (free-text providers like Ollama still configure a `defaultModel`).\n\n```ts\nresolveModelId({ connection, requested }); // requested, or defaultModel if out of allowlist\n```\n\n## Off-topic guard\n\nThe assistant helps with operating Checkstack (incidents, health checks, anomalies, automations, monitoring, and on-call) AND with questions about the assistant itself or how to use Checkstack. Two layers keep clearly unrelated requests (general coding help, creative writing, general trivia) from spending tokens on the expensive tool loop.\n\nFirst, the system prompt instructs the assistant to decline clearly unrelated requests with a one-line redirect, so even a request that slips past the classifier is steered back.\n\nSecond, a cheap topical pre-classifier runs BEFORE the agent/tool loop. It is a small `generateText` call (injectable like the title generator, defaulting to the turn's resolved model) with a tight prompt that returns a single token: `ON_TOPIC` or `OFF_TOPIC`. The following are always `ON_TOPIC`:\n\n- Checkstack operations: incidents, health checks, anomalies, automations, monitoring, on-call, the platform's data and configuration.\n- Meta/capability questions about the assistant itself: \"what can you do?\", \"who are you?\", \"help\", \"what features do you have?\".\n- Greetings and conversational openers: \"hi\", \"hello\", \"hey\".\n- How-to and conceptual questions about using Checkstack features or workflows: \"how do health checks work?\", \"how do I create an automation?\".\n\nOnly CLEARLY unrelated requests are `OFF_TOPIC`: general coding help unrelated to Checkstack, creative writing, and general trivia or knowledge questions.\n\nThe reply is parsed by a pure function that leans toward `ON_TOPIC` on anything ambiguous or unrecognized, because a false refusal of a real ops question is worse than letting one off-topic request slide.\n\n- On `OFF_TOPIC` the turn short-circuits: the expensive tool loop never runs. A canned, concise refusal is streamed back over the same SSE path the normal turn uses (so the frontend renders it identically) and persisted as the assistant message. The refusal nudges the user toward supported topics rather than just declining.\n- The classifier is fail-open: if the classifier model call throws, the turn proceeds normally. A classifier hiccup must never block legitimate use.\n- The classifier's own small token usage is recorded against the shared `ai_spend` ledger, exactly like any other model call, so it is accounted toward the spend cap.\n\n```ts\nbuildClassifierPrompt({ userText }); // { system, prompt } for the cheap call\nparseClassifierVerdict(raw); // \"ON_TOPIC\" | \"OFF_TOPIC\" (ambiguous -> ON_TOPIC)\n```\n\n## Per-integration LLM spend cap\n\nEach OpenAI-compatible connection may carry an optional `spendCap`. It is OFF by default: no cap is enforced unless you configure one in the connection's settings form. The cap is a token-count budget, not a USD budget, because token counts are deterministic and provider-agnostic. Every OpenAI-compatible provider (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio) reports token usage through the AI SDK, but only some publish a price table and self-hosted models have none, so a USD cap would need a per-model pricing table that drifts and is meaningless for local models.\n\n```ts\nspendCap: { tokenBudget: 200000, windowMinutes: 60 } // optional; omit for no cap\n```\n\nWhen a cap is set, the loop refuses a new turn once the principal's token usage against that integration in the trailing `windowMinutes` reaches `tokenBudget`, returning a clear spend-exceeded error (HTTP 429). Spend is a rolling-window SUM over the shared `ai_spend` ledger: every completed turn appends one row with the AI SDK's reported input and output tokens, keyed by integration and principal. Because the sum is read from the same shared table every pod writes to, the cap holds across all pods, exactly like the per-principal tool rate-limit budget. An in-memory per-pod token counter would let N pods each allow the cap, which a single-process test could never catch, so the ledger is durable Postgres and the cross-pod count is verified in `core/ai-backend/src/rate-limit/spend-ledger.it.test.ts`.\n\n## Dates and timezones\n\nThe model produces dates as text, so the chat enforces an unambiguous wire contract: every date-time a tool receives must be RFC 3339 with an EXPLICIT timezone offset (`2026-07-01T22:00:00Z` or `2026-07-01T22:00:00+02:00`). Zone-less values (`2026-07-01T22:00:00`) and date-only values (`2026-07-01`) are rejected, because feeding a zone-less string to `new Date()` would interpret it in the pod's local zone and the same string could then resolve to different instants on different pods. A rejected value comes back to the model as a tool-input error naming the field and the requirement, so the model repairs the call itself. The contract is enforced centrally for every tool input and structured output, gated to date fields, in `core/ai-backend/src/chat/model-schema.ts`.\n\nTo turn an operator's bare \"22:00\" into an offset, the model needs a reference timezone. The browser sends its IANA zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) with every turn, and that zone is folded into the system prompt. So by default each operator's times are interpreted in their own browser timezone, with no configuration.\n\nWhen no browser zone is available (a headless automation \"AI Action\", or a client without `Intl`), the reference zone falls back to the host/container timezone, NOT to UTC. Operators override it by setting the container's `TZ`:\n\n```env\n# Reference timezone for AI date interpretation when no browser zone is sent\n# (e.g. automation AI Actions). Any IANA zone id.\nTZ=Europe/Berlin\n```\n\nThis only affects how a bare time is interpreted into an offset; storage is always an absolute instant. The regular (non-AI) UI is unaffected: its date pickers produce real `Date` objects, which serialize as absolute instants and render back in each viewer's own browser zone.\n\n## No secret leaves the backend\n\nThe integration API key is stored in the Secrets Vault and read only on the backend when building the model provider. The chat RPCs expose only non-secret model UX metadata (`listChatIntegrations` returns connection id, name, default model, and the allowlist). The streamed response carries tokens, tool calls, and tool results (already redacted by their source procedures), never the credential. The no-secret-leak guarantee is regression-guarded across every AI DTO in `core/ai-backend/src/hardening/no-secret-leak.test.ts`.\n\nThe free-form `ai_messages.content` and `model_messages` bags are an exception that could, in principle, carry a credential if a buggy or malicious tool result smuggled one in. That guarantee is no longer merely architectural: `appendMessage` runs `scrubContent` on every message write, redacting any credential-shaped key (`apiKey`, `authorization`, `password`, `x-secret`, and similar) and any high-confidence credential value (an `sk-...` key, a `Bearer` token) before the row reaches Postgres. The scrub is conservative, so ordinary chat prose that merely mentions the word \"token\" or \"password\" is preserved; only credentials are stripped. The canary regression test injects a secret into message content and asserts it is stripped on write in `core/ai-backend/src/chat/scrub-content.test.ts` and `core/ai-backend/src/hardening/no-secret-leak.test.ts`.\n\n## Related\n\nChat shares the [tool registry](/checkstack/developer-guide/ai/tool-registry/) and resolver with the [MCP server](/checkstack/developer-guide/ai/mcp-server/), and gates mutating tools through [propose and apply](/checkstack/developer-guide/ai/propose-apply/). A model that picks a tool the principal cannot use is refused server-side (guarded in `core/ai-backend/src/chat/agent-loop.test.ts` and `core/ai-backend/src/hardening/handler-authz.test.ts`), and cross-pod conversation readback is verified in `core/ai-backend/src/chat/conversation-store.it.test.ts`. See the [AI platform overview](/checkstack/developer-guide/ai/) for the full security model.",
|
|
89
90
|
"truncated": false
|
|
90
91
|
},
|
|
91
92
|
{
|
|
@@ -277,7 +278,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
277
278
|
"Security model",
|
|
278
279
|
"See also"
|
|
279
280
|
],
|
|
280
|
-
"content": "This guide is for **plugin authors** — anyone publishing a Checkstack plugin\nthat operators install via the runtime Plugin Manager UI. It covers the\nrequired `package.json` shape, how to pack with the\n`@checkstack/scripts plugin-pack` CLI, single-package vs bundle mode, and\nrelease workflow patterns for npm, GitHub, and direct tarball delivery.\n\nIf you only want to *consume* a plugin as a platform operator, see\n[Install a plugin](/checkstack/user-guide/guides/install-a-plugin/) for\nthe Plugin Manager UI walkthrough.\n\n> **Looking for the dev loop?** This doc covers the *distribution\n> mechanics* (packing, bundles, channels). For local development — add\n> `@checkstack/dev-server` as a devDependency, wire `\"dev\": \"checkstack-dev\"`\n> into your `package.json` scripts, and run `bun run dev` — see\n> [Developing Plugins in Isolation](/checkstack/developer-guide/getting-started/plugin-development/).\n\n> **Distinction:** monorepo-internal plugins (the ones living in `core/` and\n> `plugins/` of this repo) are loaded automatically at boot via filesystem\n> discovery. Everything below is about plugins that ship **independently**\n> via npm / GitHub release / tarball upload — the mechanism the runtime\n> Plugin Manager uses.\n\n## Anatomy of an installable plugin\n\nEvery installable plugin is just an npm package whose `package.json` declares\na `checkstack` block. The platform's install pipeline validates this block\n(plus a few standard fields) on every install.\n\n### Required `package.json` fields\n\n| Field | Source | Notes |\n|---------------------------|-----------------------|---------------------------------------------------------------------------------------------|\n| `name` | standard | Must be the npm package name. Scoped names (`@org/foo`) are fine. |\n| `version` | standard | A valid semver string. Used for compatibility checks. |\n| `description` | standard | One-line summary shown in the install confirmation modal. |\n| `author` | standard | String (`\"Jane <jane@example.com>\"`) or object (`{ name, email?, url? }`). |\n| `license` | standard | Any SPDX identifier (or `SEE LICENSE IN ...`). |\n| `checkstack.type` | Checkstack | One of `\"backend\"`, `\"frontend\"`, `\"common\"`. |\n| `checkstack.pluginId` | Checkstack | Stable runtime id (e.g. `\"healthcheck-http\"`). Must match `pluginId` in `plugin-metadata.ts`.|\n\n### Optional fields\n\n| Field | Notes |\n|------------------------------------|----------------------------------------------------------------------------------------------------|\n| `homepage` | Linked from the install confirmation modal. |\n| `repository` | Standard `repository` form. Surfaced in the admin UI for the operator's reference. |\n| `checkstack.bundle` | Array of sibling package names that install/uninstall atomically with this one. Set on the **primary** package only. |\n| `checkstack.usageInstructions` | Markdown string shown in the install confirmation modal. Use it to describe required env vars, config, integrations, etc. |\n| `checkstack.allowInstallScripts` | Default `false`. When `true`, the platform runs `bun install` *without* `--ignore-scripts`. Surfaces in the security warning. Use sparingly — it's the loudest dial-up of trust requirements during install. |\n\nA minimal valid package.json:\n\n```json\n{\n \"name\": \"@my-org/widget-backend\",\n \"version\": \"1.2.3\",\n \"description\": \"Widget tracker for Checkstack\",\n \"author\": \"ACME Corp\",\n \"license\": \"MIT\",\n \"checkstack\": {\n \"type\": \"backend\",\n \"pluginId\": \"widget\"\n },\n \"scripts\": {\n \"pack\": \"bunx @checkstack/scripts plugin-pack\"\n }\n}\n```\n\nThe `pack` script is the single supported entrypoint. Do not call\n`bun pm pack` directly — `plugin-pack` validates metadata before packing,\ncatches issues at build time instead of install time.\n\n## The `plugin-pack` CLI\n\nInstall once per machine via npx-style runner:\n\n```bash\nbunx @checkstack/scripts plugin-pack --help\n```\n\n```\nUsage: checkstack-scripts plugin-pack [options]\n\nOptions:\n --bundle Pack the primary plus every sibling declared in\n package.json#checkstack.bundle into a single outer\n tarball with a bundle.json manifest.\n --out-dir <dir> Output directory (default: ./dist)\n --validate-only Only validate metadata; do not pack.\n --cwd <dir> Run as if invoked from <dir> (default: process.cwd())\n --help, -h Show this message.\n```\n\n### What it does (in order)\n\n1. Reads `<cwd>/package.json` and validates it against\n `installPackageMetadataSchema` — the same Zod schema the runtime install\n pipeline uses. Failures are reported with the specific field path so you\n know exactly what to fix.\n2. Runs `bun run typecheck` and `bun run lint` if those scripts exist.\n Mirrors what CI does — catches type/lint regressions before packing.\n3. Resolves any `workspace:*` dependency ranges to concrete versions read\n from sibling `package.json`s. If you publish from a workspace, **never**\n ship `workspace:*` to npm — `plugin-pack` rewrites them at pack time and\n restores your source on disk afterward, so your dev tree is unchanged.\n4. Calls `bun pm pack --destination <out-dir>` to produce the tarball.\n5. (Bundle mode only) Wraps the per-package tarballs in an outer\n `<name>-<version>-bundle.tgz` with a `bundle.json` manifest.\n\n### Two output modes\n\n**Per-package mode** (default — what you publish to npm):\n\n```bash\ncd packages/widget-backend\nbun run pack\n# → dist/my-org-widget-backend-1.2.3.tgz\n```\n\n**Bundle mode** (what you attach to a GitHub release or upload via the\nPlugin Manager UI):\n\n```bash\ncd packages/widget-backend # the *primary* — the one with checkstack.bundle\nbun run pack -- --bundle\n# → dist/my-org-widget-backend-1.2.3-bundle.tgz\n```\n\nBundle tarballs are **never** published to npm. npm always gets the\nper-package `.tgz`s individually.\n\n## Multi-package plugins (bundles)\n\nA \"plugin\" the operator installs may consist of several npm packages — the\nclassic example is a backend (`-backend`), a frontend (`-frontend`), and a\nshared types package (`-common`). The platform installs and uninstalls\nall of them atomically.\n\n### Declaring a bundle\n\nPick one package as the **primary** (typically `-backend`) and add a\n`checkstack.bundle` array listing the sibling package names:\n\n```json\n// my-org-widget-backend/package.json\n{\n \"name\": \"@my-org/widget-backend\",\n \"version\": \"1.2.3\",\n \"checkstack\": {\n \"type\": \"backend\",\n \"pluginId\": \"widget\",\n \"bundle\": [\n \"@my-org/widget-common\",\n \"@my-org/widget-frontend\"\n ]\n }\n}\n```\n\nSiblings should NOT carry `checkstack.bundle` — only the primary does.\nSiblings can still ship the same `pluginId` if they're part of the same\nlogical plugin.\n\n### Versioning rule\n\nAll siblings in a bundle **must share the same `version`** at pack time.\nThe compatibility checker resolves bundle-internal `@checkstack/*`\ndependencies against the bundle's package set first, falling back to the\nplatform's loaded versions only when the dep isn't part of the bundle.\nMismatched sibling versions will fail the install with a clear message.\n\nUse changesets or a release tool that bumps siblings in lockstep.\n\n### Installing bundles via npm\n\nFor npm distribution, **publish each sibling separately** as a normal npm\npackage. The platform installs the primary by name; on `previewInstall`,\nthe runtime resolves each sibling from `checkstack.bundle` against the same\nregistry and pins to the primary's exact version. The Plugin Manager UI\nshows the full list before the operator confirms.\n\n```bash\n# In CI, publish each sibling\ncd packages/widget-common && bun publish --access public\ncd packages/widget-backend && bun publish --access public\ncd packages/widget-frontend && bun publish --access public\n```\n\n### Distributing bundles via GitHub release or tarball upload\n\nFor GitHub or direct upload, use **bundle mode** to produce a single outer\ntarball that contains every sibling and a manifest:\n\n```bash\nbun run pack -- --bundle\n```\n\nThe result has this layout:\n\n```\nmy-org-widget-backend-1.2.3-bundle.tgz\n├── bundle.json # manifest\n└── packages/\n ├── my-org-widget-common-1.2.3.tgz\n ├── my-org-widget-backend-1.2.3.tgz\n └── my-org-widget-frontend-1.2.3.tgz\n```\n\n`bundle.json`:\n\n```json\n{\n \"bundleVersion\": 1,\n \"primary\": \"@my-org/widget-backend\",\n \"packages\": [\n { \"name\": \"@my-org/widget-backend\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-backend-1.2.3.tgz\" },\n { \"name\": \"@my-org/widget-common\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-common-1.2.3.tgz\" },\n { \"name\": \"@my-org/widget-frontend\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-frontend-1.2.3.tgz\" }\n ]\n}\n```\n\nAttach this single tarball to your GitHub release; the platform unpacks\nall siblings on install.\n\n## Compatibility (no manual declaration needed)\n\nYou do **not** declare a \"compatible Checkstack version\" anywhere. The\nplatform reads the semver ranges in your plugin's `dependencies` block and\nchecks each `@checkstack/*` entry with `semver.satisfies` against the\nloaded core packages.\n\n```jsonc\n{\n \"dependencies\": {\n \"@checkstack/backend-api\": \"^0.20.0\", // → must match what's loaded\n \"@checkstack/widget-common\": \"^0.1.0\", // → resolved from bundle if present\n \"lodash\": \"^4.0.0\" // → ignored (not @checkstack/*)\n }\n}\n```\n\nIf the platform has `@checkstack/backend-api@2.0.0` loaded but your plugin\ndeclared `^1.0.0`, install fails with a `BAD_REQUEST` and the message\n`\"Plugin '...' requires @checkstack/backend-api@^1.0.0 but this platform\nhas 2.0.0.\"`\n\n`workspace:*` ranges are explicitly rejected by the runtime — they're a\npack-time-only construct. The `plugin-pack` CLI resolves them\nautomatically.\n\n## Distribution channels\n\n| Channel | Best for | Pack mode |\n|----------------------|---------------------------------------------|----------------|\n| npm (public) | Community plugins; broad discoverability | per-package |\n| npm (private) | Org-internal plugins behind an npm registry | per-package |\n| GitHub release | Plugins not published to npm; signed artifacts | `--bundle` |\n| GitHub Enterprise | Air-gapped / private GHE deployments | `--bundle` |\n| Tarball upload (UI) | Local dev; one-off testing | per-package or `--bundle` |\n\n### npm\n\nPublish per-package as you would any npm library. The platform's npm\ninstaller hits the registry's metadata endpoint\n(`<registry>/<package>/<version>`), downloads the `dist.tarball` URL, and\nstores the bytes in Postgres. Configurable per-source via\n`PluginSource.registry` so deployments behind a private registry (Verdaccio,\nJFrog, GitHub Packages, …) work without env-var twiddling.\n\n### GitHub releases\n\nConvention: each release tag has **exactly one** `.tgz` asset (the bundle\ntarball produced by `plugin-pack --bundle`). The platform fetches via the\nGitHub API, downloads the asset, validates, and stores.\n\nFor repos with multiple `.tgz` assets, the install source carries an\noptional `assetName` field — the Plugin Manager UI exposes this.\n\n### GitHub Enterprise\n\nThree additional fields on the install source:\n\n| Source field | Meaning |\n|----------------|--------------------------------------------------------------------------------|\n| `apiBaseUrl` | Your GHE API root, e.g. `https://github.example.com/api/v3`. Defaults to public github.com when omitted. |\n| `tokenEnvVar` | Name of the env var on the platform that holds the PAT. Defaults to `GITHUB_TOKEN`. |\n\nMultiple GitHub instances in the same deployment? Set different\n`tokenEnvVar` values per source — e.g. `GITHUB_TOKEN_PUBLIC`,\n`GITHUB_TOKEN_GHE`.\n\n### Tarball upload (Plugin Manager UI)\n\nOperators can drag-and-drop a `.tgz` directly. The platform:\n1. Stores the bytes in `plugin_artifacts` and returns an `artifactId`.\n2. Treats it the same as a GitHub-release source — peek the package.json or\n `bundle.json`, validate, install.\n\nUseful for local development where you don't want to push to a registry.\nThe 50MB tarball cap applies to all sources.\n\n## Recommended release workflow (GitHub Actions)\n\nA copy-paste starting point lives at\n[`plugin-release.yml`](/checkstack/examples/plugin-release.yml). The\nshort version:\n\n```yaml\nname: Release Plugin\non:\n push:\n tags: [\"v*.*.*\"]\npermissions:\n contents: write # to upload release assets\n id-token: write # for npm provenance\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: oven-sh/setup-bun@v2\n with: { bun-version: latest }\n - run: bun install --frozen-lockfile\n - run: bunx @checkstack/scripts plugin-pack --bundle\n - name: Attach bundle to release\n uses: softprops/action-gh-release@v2\n with:\n files: dist/*-bundle.tgz\n # Optional: publish per-package to npm in a matrix\n # - run: cd packages/widget-backend && bun publish --access public\n # env:\n # NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}\n```\n\nFor per-package npm publish, run `bun publish --access public` in each\nsibling directory. Don't run `bun publish` on the bundle tarball — npm\nwon't accept it.\n\n## CI-friendly metadata validation\n\nLint your `package.json` against the install-time schema in your own CI,\nwithout pack:\n\n```bash\nbunx @checkstack/scripts plugin-pack --validate-only\n```\n\nReturns non-zero on any schema violation with a per-field error list. We\nrun this in our own publish script before each `bun publish` —\n[`scripts/publish-packages.ts`](https://github.com/enyineer/checkstack/blob/main/scripts/publish-packages.ts)\nis a working reference if you want to mirror the pattern.\n\n## Security model\n\nPlugins run **in-process with full platform access** — same Bun event\nloop, same database connection, same secrets. The platform's defenses are:\n\n1. **Strong typed-confirmation modal** — operator types the exact plugin\n name to install. No \"click OK\" muscle-memory bypass.\n2. **`bun install --ignore-scripts`** — postinstall scripts in the plugin\n and its transitive deps don't execute. Opt out per-plugin via\n `checkstack.allowInstallScripts: true`; this surfaces in the security\n warning so the operator sees it.\n3. **Source disclosure** — the install modal shows source type\n (npm/github/tarball), the package name + version, the author, license,\n homepage, and any compatibility issues before commit.\n4. **Bundle atomicity** — partial installs aren't possible; if any sibling\n fails validation, none commit.\n\nThe platform does **not** sandbox plugin code (process isolation, V8\ncontexts, etc. — see the design doc for why). Operators should only install\nplugins from trusted authors. Plugin authors should make their distribution\nchannels easy to audit (signed tags, reproducible builds, public source).\n\n## See also\n\n- [Developing Plugins in Isolation](/checkstack/developer-guide/getting-started/plugin-development/) —\n running Checkstack locally to develop your plugin, iteration patterns\n- [Plugin Architecture Overview](/checkstack/developer-guide/architecture/plugin-system/) — how plugins fit\n into Checkstack at runtime\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/) — writing the\n `-backend` package\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/) — writing the\n `-frontend` package\n- [`plugin-release.yml`](/checkstack/examples/plugin-release.yml) —\n GitHub Actions release workflow template",
|
|
281
|
+
"content": "This guide is for **plugin authors** — anyone publishing a Checkstack plugin\nthat operators install via the runtime Plugin Manager UI. It covers the\nrequired `package.json` shape, how to pack with the\n`@checkstack/scripts plugin-pack` CLI, single-package vs bundle mode, and\nrelease workflow patterns for npm, GitHub, and direct tarball delivery.\n\nIf you only want to *consume* a plugin as a platform operator, see\n[Install a plugin](/checkstack/user-guide/guides/install-a-plugin/) for\nthe Plugin Manager UI walkthrough.\n\n> **Looking for the dev loop?** This doc covers the *distribution\n> mechanics* (packing, bundles, channels). For local development — add\n> `@checkstack/dev-server` as a devDependency, wire `\"dev\": \"checkstack-dev\"`\n> into your `package.json` scripts, and run `bun run dev` — see\n> [Developing Plugins in Isolation](/checkstack/developer-guide/getting-started/plugin-development/).\n\n> **Distinction:** monorepo-internal plugins (the ones living in `core/` and\n> `plugins/` of this repo) are loaded automatically at boot via filesystem\n> discovery. Everything below is about plugins that ship **independently**\n> via npm / GitHub release / tarball upload — the mechanism the runtime\n> Plugin Manager uses.\n\n## Anatomy of an installable plugin\n\nEvery installable plugin is just an npm package whose `package.json` declares\na `checkstack` block. The platform's install pipeline validates this block\n(plus a few standard fields) on every install.\n\n### Required `package.json` fields\n\n| Field | Source | Notes |\n|---------------------------|-----------------------|---------------------------------------------------------------------------------------------|\n| `name` | standard | Must be the npm package name. Scoped names (`@org/foo`) are fine. |\n| `version` | standard | A valid semver string. Used for compatibility checks. |\n| `description` | standard | One-line summary shown in the install confirmation modal. |\n| `author` | standard | String (`\"Jane <jane@example.com>\"`) or object (`{ name, email?, url? }`). |\n| `license` | standard | Any SPDX identifier (or `SEE LICENSE IN ...`). |\n| `checkstack.type` | Checkstack | One of `\"backend\"`, `\"frontend\"`, `\"common\"`. |\n| `checkstack.pluginId` | Checkstack | Stable runtime id (e.g. `\"healthcheck-http\"`). Must match `pluginId` in `plugin-metadata.ts`.|\n\n### Optional fields\n\n| Field | Notes |\n|------------------------------------|----------------------------------------------------------------------------------------------------|\n| `homepage` | Linked from the install confirmation modal. |\n| `repository` | Standard `repository` form. Surfaced in the admin UI for the operator's reference. |\n| `checkstack.bundle` | Array of sibling package names that install/uninstall atomically with this one. Set on the **primary** package only. |\n| `checkstack.usageInstructions` | Markdown string shown in the install confirmation modal. Use it to describe required env vars, config, integrations, etc. |\n| `checkstack.allowInstallScripts` | Default `false`. When `true`, the platform runs `bun install` *without* `--ignore-scripts`. Surfaces in the security warning. Use sparingly — it's the loudest dial-up of trust requirements during install. |\n\nA minimal valid package.json:\n\n```json\n{\n \"name\": \"@my-org/widget-backend\",\n \"version\": \"1.2.3\",\n \"description\": \"Widget tracker for Checkstack\",\n \"author\": \"ACME Corp\",\n \"license\": \"MIT\",\n \"checkstack\": {\n \"type\": \"backend\",\n \"pluginId\": \"widget\"\n },\n \"devDependencies\": {\n \"@checkstack/scripts\": \"^0.4.0\"\n },\n \"scripts\": {\n \"pack\": \"checkstack-scripts plugin-pack\"\n }\n}\n```\n\nThe `pack` script is the single supported entrypoint. It calls the\n`checkstack-scripts` bin from the `@checkstack/scripts` devDependency (resolved\nfrom `node_modules/.bin`), so a committed script always runs the pinned version\n- not a cache-resolved \"latest\". Do not call `bun pm pack` directly -\n`plugin-pack` validates metadata before packing, catching issues at build time\ninstead of install time.\n\n## The `plugin-pack` CLI\n\nFor a one-off run without installing, use `bunx` with an explicit `@latest` so\nBun does not execute a stale cached copy:\n\n```bash\nbunx @checkstack/scripts@latest plugin-pack --help\n```\n\n```\nUsage: checkstack-scripts plugin-pack [options]\n\nOptions:\n --bundle Pack the primary plus every sibling declared in\n package.json#checkstack.bundle into a single outer\n tarball with a bundle.json manifest.\n --out-dir <dir> Output directory (default: ./dist)\n --validate-only Only validate metadata; do not pack.\n --cwd <dir> Run as if invoked from <dir> (default: process.cwd())\n --help, -h Show this message.\n```\n\n### What it does (in order)\n\n1. Reads `<cwd>/package.json` and validates it against\n `installPackageMetadataSchema` — the same Zod schema the runtime install\n pipeline uses. Failures are reported with the specific field path so you\n know exactly what to fix.\n2. Runs `bun run typecheck` and `bun run lint` if those scripts exist.\n Mirrors what CI does — catches type/lint regressions before packing.\n3. Resolves any `workspace:*` dependency ranges to concrete versions read\n from sibling `package.json`s. If you publish from a workspace, **never**\n ship `workspace:*` to npm — `plugin-pack` rewrites them at pack time and\n restores your source on disk afterward, so your dev tree is unchanged.\n4. Calls `bun pm pack --destination <out-dir>` to produce the tarball.\n5. (Bundle mode only) Wraps the per-package tarballs in an outer\n `<name>-<version>-bundle.tgz` with a `bundle.json` manifest.\n\n### Two output modes\n\n**Per-package mode** (default — what you publish to npm):\n\n```bash\ncd packages/widget-backend\nbun run pack\n# → dist/my-org-widget-backend-1.2.3.tgz\n```\n\n**Bundle mode** (what you attach to a GitHub release or upload via the\nPlugin Manager UI):\n\n```bash\ncd packages/widget-backend # the *primary* — the one with checkstack.bundle\nbun run pack -- --bundle\n# → dist/my-org-widget-backend-1.2.3-bundle.tgz\n```\n\nBundle tarballs are **never** published to npm. npm always gets the\nper-package `.tgz`s individually.\n\n## Multi-package plugins (bundles)\n\nA \"plugin\" the operator installs may consist of several npm packages — the\nclassic example is a backend (`-backend`), a frontend (`-frontend`), and a\nshared types package (`-common`). The platform installs and uninstalls\nall of them atomically.\n\n### Declaring a bundle\n\nPick one package as the **primary** (typically `-backend`) and add a\n`checkstack.bundle` array listing the sibling package names:\n\n```json\n// my-org-widget-backend/package.json\n{\n \"name\": \"@my-org/widget-backend\",\n \"version\": \"1.2.3\",\n \"checkstack\": {\n \"type\": \"backend\",\n \"pluginId\": \"widget\",\n \"bundle\": [\n \"@my-org/widget-common\",\n \"@my-org/widget-frontend\"\n ]\n }\n}\n```\n\nSiblings should NOT carry `checkstack.bundle` — only the primary does.\nSiblings can still ship the same `pluginId` if they're part of the same\nlogical plugin.\n\n### Versioning rule\n\nAll siblings in a bundle **must share the same `version`** at pack time.\nThe compatibility checker resolves bundle-internal `@checkstack/*`\ndependencies against the bundle's package set first, falling back to the\nplatform's loaded versions only when the dep isn't part of the bundle.\nMismatched sibling versions will fail the install with a clear message.\n\nUse changesets or a release tool that bumps siblings in lockstep.\n\n### Installing bundles via npm\n\nFor npm distribution, **publish each sibling separately** as a normal npm\npackage. The platform installs the primary by name; on `previewInstall`,\nthe runtime resolves each sibling from `checkstack.bundle` against the same\nregistry and pins to the primary's exact version. The Plugin Manager UI\nshows the full list before the operator confirms.\n\n```bash\n# In CI, publish each sibling\ncd packages/widget-common && bun publish --access public\ncd packages/widget-backend && bun publish --access public\ncd packages/widget-frontend && bun publish --access public\n```\n\n### Distributing bundles via GitHub release or tarball upload\n\nFor GitHub or direct upload, use **bundle mode** to produce a single outer\ntarball that contains every sibling and a manifest:\n\n```bash\nbun run pack -- --bundle\n```\n\nThe result has this layout:\n\n```\nmy-org-widget-backend-1.2.3-bundle.tgz\n├── bundle.json # manifest\n└── packages/\n ├── my-org-widget-common-1.2.3.tgz\n ├── my-org-widget-backend-1.2.3.tgz\n └── my-org-widget-frontend-1.2.3.tgz\n```\n\n`bundle.json`:\n\n```json\n{\n \"bundleVersion\": 1,\n \"primary\": \"@my-org/widget-backend\",\n \"packages\": [\n { \"name\": \"@my-org/widget-backend\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-backend-1.2.3.tgz\" },\n { \"name\": \"@my-org/widget-common\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-common-1.2.3.tgz\" },\n { \"name\": \"@my-org/widget-frontend\", \"version\": \"1.2.3\",\n \"tarball\": \"packages/my-org-widget-frontend-1.2.3.tgz\" }\n ]\n}\n```\n\nAttach this single tarball to your GitHub release; the platform unpacks\nall siblings on install.\n\n## Compatibility (no manual declaration needed)\n\nYou do **not** declare a \"compatible Checkstack version\" anywhere. The\nplatform reads the semver ranges in your plugin's `dependencies` block and\nchecks each `@checkstack/*` entry with `semver.satisfies` against the\nloaded core packages.\n\n```jsonc\n{\n \"dependencies\": {\n \"@checkstack/backend-api\": \"^0.20.0\", // → must match what's loaded\n \"@checkstack/widget-common\": \"^0.1.0\", // → resolved from bundle if present\n \"lodash\": \"^4.0.0\" // → ignored (not @checkstack/*)\n }\n}\n```\n\nIf the platform has `@checkstack/backend-api@2.0.0` loaded but your plugin\ndeclared `^1.0.0`, install fails with a `BAD_REQUEST` and the message\n`\"Plugin '...' requires @checkstack/backend-api@^1.0.0 but this platform\nhas 2.0.0.\"`\n\n`workspace:*` ranges are explicitly rejected by the runtime — they're a\npack-time-only construct. The `plugin-pack` CLI resolves them\nautomatically.\n\n## Distribution channels\n\n| Channel | Best for | Pack mode |\n|----------------------|---------------------------------------------|----------------|\n| npm (public) | Community plugins; broad discoverability | per-package |\n| npm (private) | Org-internal plugins behind an npm registry | per-package |\n| GitHub release | Plugins not published to npm; signed artifacts | `--bundle` |\n| GitHub Enterprise | Air-gapped / private GHE deployments | `--bundle` |\n| Tarball upload (UI) | Local dev; one-off testing | per-package or `--bundle` |\n\n### npm\n\nPublish per-package as you would any npm library. The platform's npm\ninstaller hits the registry's metadata endpoint\n(`<registry>/<package>/<version>`), downloads the `dist.tarball` URL, and\nstores the bytes in Postgres. Configurable per-source via\n`PluginSource.registry` so deployments behind a private registry (Verdaccio,\nJFrog, GitHub Packages, …) work without env-var twiddling.\n\n### GitHub releases\n\nConvention: each release tag has **exactly one** `.tgz` asset (the bundle\ntarball produced by `plugin-pack --bundle`). The platform fetches via the\nGitHub API, downloads the asset, validates, and stores.\n\nFor repos with multiple `.tgz` assets, the install source carries an\noptional `assetName` field — the Plugin Manager UI exposes this.\n\n### GitHub Enterprise\n\nThree additional fields on the install source:\n\n| Source field | Meaning |\n|----------------|--------------------------------------------------------------------------------|\n| `apiBaseUrl` | Your GHE API root, e.g. `https://github.example.com/api/v3`. Defaults to public github.com when omitted. |\n| `tokenEnvVar` | Name of the env var on the platform that holds the PAT. Defaults to `GITHUB_TOKEN`. |\n\nMultiple GitHub instances in the same deployment? Set different\n`tokenEnvVar` values per source — e.g. `GITHUB_TOKEN_PUBLIC`,\n`GITHUB_TOKEN_GHE`.\n\n### Tarball upload (Plugin Manager UI)\n\nOperators can drag-and-drop a `.tgz` directly. The platform:\n1. Stores the bytes in `plugin_artifacts` and returns an `artifactId`.\n2. Treats it the same as a GitHub-release source — peek the package.json or\n `bundle.json`, validate, install.\n\nUseful for local development where you don't want to push to a registry.\nThe 50MB tarball cap applies to all sources.\n\n## Recommended release workflow (GitHub Actions)\n\nA copy-paste starting point lives at\n[`plugin-release.yml`](/checkstack/examples/plugin-release.yml). The\nshort version:\n\n```yaml\nname: Release Plugin\non:\n push:\n tags: [\"v*.*.*\"]\npermissions:\n contents: write # to upload release assets\n id-token: write # for npm provenance\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: oven-sh/setup-bun@v2\n with: { bun-version: latest }\n - run: bun install --frozen-lockfile\n - run: bunx @checkstack/scripts@latest plugin-pack --bundle\n - name: Attach bundle to release\n uses: softprops/action-gh-release@v2\n with:\n files: dist/*-bundle.tgz\n # Optional: publish per-package to npm in a matrix\n # - run: cd packages/widget-backend && bun publish --access public\n # env:\n # NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}\n```\n\nFor per-package npm publish, run `bun publish --access public` in each\nsibling directory. Don't run `bun publish` on the bundle tarball — npm\nwon't accept it.\n\n## CI-friendly metadata validation\n\nLint your `package.json` against the install-time schema in your own CI,\nwithout pack:\n\n```bash\nbunx @checkstack/scripts@latest plugin-pack --validate-only\n```\n\nReturns non-zero on any schema violation with a per-field error list. We\nrun this in our own publish script before each `bun publish` —\n[`scripts/publish-packages.ts`](https://github.com/enyineer/checkstack/blob/main/scripts/publish-packages.ts)\nis a working reference if you want to mirror the pattern.\n\n## Security model\n\nPlugins run **in-process with full platform access** — same Bun event\nloop, same database connection, same secrets. The platform's defenses are:\n\n1. **Strong typed-confirmation modal** — operator types the exact plugin\n name to install. No \"click OK\" muscle-memory bypass.\n2. **`bun install --ignore-scripts`** — postinstall scripts in the plugin\n and its transitive deps don't execute. Opt out per-plugin via\n `checkstack.allowInstallScripts: true`; this surfaces in the security\n warning so the operator sees it.\n3. **Source disclosure** — the install modal shows source type\n (npm/github/tarball), the package name + version, the author, license,\n homepage, and any compatibility issues before commit.\n4. **Bundle atomicity** — partial installs aren't possible; if any sibling\n fails validation, none commit.\n\nThe platform does **not** sandbox plugin code (process isolation, V8\ncontexts, etc. — see the design doc for why). Operators should only install\nplugins from trusted authors. Plugin authors should make their distribution\nchannels easy to audit (signed tags, reproducible builds, public source).\n\n## See also\n\n- [Developing Plugins in Isolation](/checkstack/developer-guide/getting-started/plugin-development/) —\n running Checkstack locally to develop your plugin, iteration patterns\n- [Plugin Architecture Overview](/checkstack/developer-guide/architecture/plugin-system/) — how plugins fit\n into Checkstack at runtime\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/) — writing the\n `-backend` package\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/) — writing the\n `-frontend` package\n- [`plugin-release.yml`](/checkstack/examples/plugin-release.yml) —\n GitHub Actions release workflow template",
|
|
281
282
|
"truncated": false
|
|
282
283
|
},
|
|
283
284
|
{
|
|
@@ -317,7 +318,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
317
318
|
"Hybrid",
|
|
318
319
|
"Next Steps"
|
|
319
320
|
],
|
|
320
|
-
"content": "## Introduction\n\nCheckstack is built on a **pluggable architecture** that enables extensibility, modularity, and flexible deployment options. Everything beyond the core framework is implemented as a plugin, allowing the system to scale from monolithic deployments to distributed microservices.\n\n## Core Principles\n\n### 1. Runtime Registration\n\nPlugins **MUST** be registerable at runtime. This design enables:\n- Loading plugins from remote sources without code changes\n- Hot-swapping plugins during development\n- Dynamic feature enablement based on deployment needs\n\nThe platform supports four install sources, all going through a discriminated\n`PluginSource` union and a per-source `PluginInstaller`:\n\n| Source | Use case |\n|------------|-------------------------------------------------|\n| `npm` | Public or private npm registry (configurable) |\n| `tarball` | Uploaded `.tgz` (filesystem analogue) |\n| `github` | GitHub release asset (`.tgz` packed by our CLI) |\n| `catalog` | Curated marketplace (stub — coming soon) |\n\nPlugin tarballs (single package or `--bundle`-mode multi-package) are\npersisted in `plugin_artifacts` (Postgres `bytea`). A freshly spun replica\nrecovers every runtime-installed plugin from this table at boot — no\nre-fetch from the original source is needed for replicas to come up.\n\nFor plugin authors: see [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/)\nfor the developer-facing guide on packing, bundles, npm/GitHub/tarball\ndistribution, and the `bunx @checkstack/scripts plugin-pack` CLI.\n\n### 2. Inversion of Control (IoC)\n\nPlugins register themselves with the core application through well-defined interfaces:\n- **Backend plugins** register via `BackendPluginRegistry`\n- **Frontend plugins** register via `FrontendPlugin` interface\n\nThe core calls plugin registration functions, not the other way around.\n\n### 3. Secure Service-to-Service Communication\n\nAll plugin-to-plugin communication happens via:\n- **HTTPS** for transport security\n- **Signed JWTs** for authentication\n- **Configured secrets** for token signing\n\nThis ensures security even in distributed deployments.\n\n### 4. Modular Project Structure\n\nEach plugin is a standalone npm package that can:\n- Run independently\n- Be deployed as part of a monolith\n- Be deployed as a separate microservice\n- Share code through common packages\n\n## Project Structure\n\n```\ncheckstack/\n├── core/\n│ ├── backend/ # Core backend framework\n│ ├── frontend/ # Core frontend framework\n│ ├── backend-api/ # Backend plugin API\n│ ├── frontend-api/ # Frontend plugin API\n│ ├── common/ # Shared core types\n│ ├── ui/ # Shared UI components\n│ │\n│ ├── auth-*/ # Authentication (essential)\n│ ├── catalog-*/ # Entity management (essential)\n│ ├── notification-*/ # Notifications (essential)\n│ ├── healthcheck-*/ # Health monitoring (essential)\n│ ├── satellite-*/ # Remote satellite agents (essential)\n│ ├── queue-*/ # Queue abstraction (essential)\n│ └── theme-*/ # UI theming (essential)\n│\n└── plugins/ # Replaceable providers only\n ├── auth-github-backend/ # GitHub OAuth provider\n ├── auth-credential-backend/ # Username/password auth\n ├── auth-ldap-backend/ # LDAP auth provider\n ├── queue-bullmq-*/ # BullMQ implementation\n ├── queue-memory-*/ # In-memory implementation\n └── healthcheck-http-backend/ # HTTP health strategy\n```\n\n> **Note:** See [Packages vs Plugins Architecture](/checkstack/developer-guide/architecture/packages-vs-plugins/) for decision criteria on when to create a package vs a plugin.\n\n## Package Types\n\nCheckstack uses a strict package type system to maintain clean architecture:\n\n| Package Type | Suffix/Pattern | Purpose | Can Depend On |\n|--------------|---------------|---------|---------------|\n| **Backend** | `-backend` | REST APIs, business logic, database | Backend packages, common packages |\n| **Frontend** | `-frontend` | UI components, pages, routing | Frontend packages, common packages |\n| **Common** | `-common` | Shared types, access rules, constants | Common packages only |\n| **Node** | `-node` | Backend-only shared code | Backend packages, common packages |\n| **React** | `-react` | Frontend-only shared components | Frontend packages, common packages |\n\n### Dependency Rules\n\nThese rules are **automatically enforced** by the dependency linter:\n\n- ✅ **Common** → Common only\n- ✅ **Frontend** → Frontend or Common\n- ✅ **Backend** → Backend or Common\n- ❌ **Common** → Backend or Frontend (FORBIDDEN)\n- ❌ **Frontend** → Backend (FORBIDDEN)\n\nSee [dependency-linter.md](/checkstack/developer-guide/tooling/dependency-linter/) for details.\n\n## Plugin Lifecycle\n\n### Backend Plugin Lifecycle\n\nBackend plugins use a **two-phase initialization** to ensure cross-plugin communication works correctly:\n\n```mermaid\ngraph TD\n A[Plugin Discovery] --> B[Load Plugin Module]\n B --> C[Create Plugin Schema]\n C --> D[Run Migrations]\n D --> E[Call register function]\n E --> F[Register Access Rules]\n E --> G[Register Services]\n E --> H[Register Extension Points]\n E --> I[Register Init Function]\n \n subgraph \"Phase 2: Init\"\n I --> J[Resolve Dependencies]\n J --> K[Call init - Register routers]\n end\n \n subgraph \"Phase 3: After Plugins Ready\"\n K --> L[All Plugins Initialized]\n L --> M[Call afterPluginsReady]\n M --> N[Cross-plugin RPC + Hooks]\n end\n \n N --> O[Plugin Active]\n```\n\n> **Key Point:** The `init` function registers routers and services. The `afterPluginsReady` callback runs after ALL plugins have initialized, making it safe to:\n> - Call other plugins via RPC\n> - Subscribe to hooks (`onHook`)\n> - Emit hooks (`emitHook`)\n\n### Frontend Plugin Lifecycle\n\n```mermaid\ngraph TD\n A[Plugin Discovery] --> B[Load Plugin Module]\n B --> C[Register APIs]\n C --> D[Register Routes]\n D --> E[Register Nav Items]\n E --> F[Register Extensions]\n F --> G[Plugin Active]\n```\n\n## Database Isolation\n\nEach backend plugin gets its own **isolated PostgreSQL schema**:\n\n```\nDatabase: checkstack\n├── Schema: public (core only)\n├── Schema: plugin_catalog-backend\n├── Schema: plugin_auth-backend\n└── Schema: plugin_healthcheck-backend\n```\n\n### Benefits\n\n- **Namespace isolation**: No table name conflicts\n- **Independent migrations**: Each plugin manages its own schema\n- **Security**: Plugins can't access each other's data directly\n- **Scalability**: Easy to split into separate databases later\n\nSee [Drizzle Schema Isolation](/checkstack/developer-guide/backend/drizzle-schema/) for implementation details.\n\n## Extension Points\n\nExtension points enable plugins to provide implementations for core functionality:\n\n### Backend Extension Points\n\n- **HealthCheckStrategy**: Implement custom health check methods\n- **ExporterStrategy**: Export metrics and data in various formats\n- **NotificationStrategy**: Send notifications via different channels\n- **AuthenticationStrategy**: Integrate authentication providers\n\n### Frontend Extension Points\n\n- **Slots**: Inject UI components into predefined locations\n- **Routes**: Add new pages to the application\n- **APIs**: Provide client-side services\n\nSee [Extension Points](/checkstack/developer-guide/frontend/extension-points/) for detailed documentation.\n\n## Configuration Management\n\nPlugins use **versioned configurations** to support backward compatibility:\n\n```typescript\ninterface VersionedConfig<T> {\n version: number;\n pluginId: string;\n data: T;\n migratedAt?: Date;\n originalVersion?: number;\n}\n```\n\nThis enables:\n- Schema evolution without breaking existing configs\n- Automatic migration of old configurations\n- Rollback support\n\nSee [versioned-configs.md](/checkstack/developer-guide/backend/versioned-configs/) for details.\n\n## Communication Patterns\n\n### Frontend ↔ Backend\n\n```mermaid\nsequenceDiagram\n participant F as Frontend Plugin\n participant FA as Fetch API\n participant R as Router\n participant B as Backend Plugin\n \n F->>FA: Request with credentials\n FA->>R: HTTPS + JWT\n R->>R: Validate JWT\n R->>R: Check access\n R->>B: Route to plugin\n B->>R: Response\n R->>FA: JSON response\n FA->>F: Typed data\n```\n\n### Backend ↔ Backend\n\n```mermaid\nsequenceDiagram\n participant P1 as Plugin A\n participant S as Service Registry\n participant P2 as Plugin B\n \n P1->>S: Get service reference\n S->>P1: Service instance\n P1->>P2: Call via HTTPS + JWT\n P2->>P2: Validate service token\n P2->>P1: Response\n```\n\n### WebSocket (Plugin-Registered)\n\nPlugins can register custom WebSocket endpoints via the **WebSocket Route Registry**. All routes are automatically namespaced by plugin ID to prevent collisions:\n\n```typescript\n// In satellite-backend's afterPluginsReady:\nwsRegistry.register(\"/\", wsHandler);\n// → Available at /api/ws/satellite\n\n// Plugins can register sub-paths too:\nwsRegistry.register(\"/events\", eventsHandler);\n// → Available at /api/ws/{pluginId}/events\n```\n\nThe registry uses the same **scoped factory pattern** as RPC and health check registries — plugins never provide their ID manually.\n\n> **Note:** The signal/realtime WebSocket (`/api/signals/ws`) uses Bun's native pub/sub and is handled separately from the registry.\n\n## Access System\n\nAccess rules are defined in common packages and registered by backend plugins:\n\n```typescript\n// In catalog-common\nexport const access = {\n entityRead: {\n id: \"entity.read\",\n description: \"Read Systems and Groups\",\n },\n} satisfies Record<string, AccessRule>;\n\n// In catalog-backend\nenv.registerAccessRules(accessRuleList);\n\n// In catalog-frontend\nconst canRead = accessApi.useAccess(access.entityRead.id);\n```\n\nThe core automatically prefixes access rules with the plugin ID: `catalog.entity.read`\n\n## Technology Stack\n\n### Backend\n- **Runtime**: Bun\n- **Framework**: Hono (HTTP routing)\n- **Database**: PostgreSQL + Drizzle ORM\n- **Validation**: Zod\n- **Testing**: Bun test runner\n\n### Frontend\n- **Framework**: React\n- **Routing**: React Router DOM\n- **UI**: ShadCN + Tailwind CSS\n- **Build**: Vite\n- **Testing**: Playwright (E2E)\n\n## Deployment Options\n\n### Monolith (Default)\n\nAll plugins run in a single process:\n```bash\nbun run dev\n```\n\n### Microservices\n\nEach plugin can run independently:\n```bash\n# Terminal 1\nbun run dev:backend --plugins=catalog-backend\n\n# Terminal 2\nbun run dev:backend --plugins=auth-backend\n\n# Terminal 3\nbun run dev:frontend\n```\n\n### Hybrid\n\nMix and match based on scaling needs:\n- Core + frequently-used plugins in monolith\n- Resource-intensive plugins as separate services\n- Geographic distribution for compliance\n\n## Next Steps\n\n- [Packages vs Plugins Architecture](/checkstack/developer-guide/architecture/packages-vs-plugins/)\n- [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/)\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/)\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/)\n- [Common Plugin Guidelines](/checkstack/developer-guide/common/plugins/)\n- [Extension Points](/checkstack/developer-guide/frontend/extension-points/)\n- [Versioned Configurations](/checkstack/developer-guide/backend/versioned-configs/)\n- [Health and Readiness Probes](/checkstack/user-guide/reference/health-probes/)\n- [Contributing Guide](/checkstack/developer-guide/getting-started/contributing/)",
|
|
321
|
+
"content": "## Introduction\n\nCheckstack is built on a **pluggable architecture** that enables extensibility, modularity, and flexible deployment options. Everything beyond the core framework is implemented as a plugin, allowing the system to scale from monolithic deployments to distributed microservices.\n\n## Core Principles\n\n### 1. Runtime Registration\n\nPlugins **MUST** be registerable at runtime. This design enables:\n- Loading plugins from remote sources without code changes\n- Hot-swapping plugins during development\n- Dynamic feature enablement based on deployment needs\n\nThe platform supports four install sources, all going through a discriminated\n`PluginSource` union and a per-source `PluginInstaller`:\n\n| Source | Use case |\n|------------|-------------------------------------------------|\n| `npm` | Public or private npm registry (configurable) |\n| `tarball` | Uploaded `.tgz` (filesystem analogue) |\n| `github` | GitHub release asset (`.tgz` packed by our CLI) |\n| `catalog` | Curated marketplace (stub — coming soon) |\n\nPlugin tarballs (single package or `--bundle`-mode multi-package) are\npersisted in `plugin_artifacts` (Postgres `bytea`). A freshly spun replica\nrecovers every runtime-installed plugin from this table at boot — no\nre-fetch from the original source is needed for replicas to come up.\n\nFor plugin authors: see [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/)\nfor the developer-facing guide on packing, bundles, npm/GitHub/tarball\ndistribution, and the `bunx @checkstack/scripts@latest plugin-pack` CLI.\n\n### 2. Inversion of Control (IoC)\n\nPlugins register themselves with the core application through well-defined interfaces:\n- **Backend plugins** register via `BackendPluginRegistry`\n- **Frontend plugins** register via `FrontendPlugin` interface\n\nThe core calls plugin registration functions, not the other way around.\n\n### 3. Secure Service-to-Service Communication\n\nAll plugin-to-plugin communication happens via:\n- **HTTPS** for transport security\n- **Signed JWTs** for authentication\n- **Configured secrets** for token signing\n\nThis ensures security even in distributed deployments.\n\n### 4. Modular Project Structure\n\nEach plugin is a standalone npm package that can:\n- Run independently\n- Be deployed as part of a monolith\n- Be deployed as a separate microservice\n- Share code through common packages\n\n## Project Structure\n\n```\ncheckstack/\n├── core/\n│ ├── backend/ # Core backend framework\n│ ├── frontend/ # Core frontend framework\n│ ├── backend-api/ # Backend plugin API\n│ ├── frontend-api/ # Frontend plugin API\n│ ├── common/ # Shared core types\n│ ├── ui/ # Shared UI components\n│ │\n│ ├── auth-*/ # Authentication (essential)\n│ ├── catalog-*/ # Entity management (essential)\n│ ├── notification-*/ # Notifications (essential)\n│ ├── healthcheck-*/ # Health monitoring (essential)\n│ ├── satellite-*/ # Remote satellite agents (essential)\n│ ├── queue-*/ # Queue abstraction (essential)\n│ └── theme-*/ # UI theming (essential)\n│\n└── plugins/ # Replaceable providers only\n ├── auth-github-backend/ # GitHub OAuth provider\n ├── auth-credential-backend/ # Username/password auth\n ├── auth-ldap-backend/ # LDAP auth provider\n ├── queue-bullmq-*/ # BullMQ implementation\n ├── queue-memory-*/ # In-memory implementation\n └── healthcheck-http-backend/ # HTTP health strategy\n```\n\n> **Note:** See [Packages vs Plugins Architecture](/checkstack/developer-guide/architecture/packages-vs-plugins/) for decision criteria on when to create a package vs a plugin.\n\n## Package Types\n\nCheckstack uses a strict package type system to maintain clean architecture:\n\n| Package Type | Suffix/Pattern | Purpose | Can Depend On |\n|--------------|---------------|---------|---------------|\n| **Backend** | `-backend` | REST APIs, business logic, database | Backend packages, common packages |\n| **Frontend** | `-frontend` | UI components, pages, routing | Frontend packages, common packages |\n| **Common** | `-common` | Shared types, access rules, constants | Common packages only |\n| **Node** | `-node` | Backend-only shared code | Backend packages, common packages |\n| **React** | `-react` | Frontend-only shared components | Frontend packages, common packages |\n\n### Dependency Rules\n\nThese rules are **automatically enforced** by the dependency linter:\n\n- ✅ **Common** → Common only\n- ✅ **Frontend** → Frontend or Common\n- ✅ **Backend** → Backend or Common\n- ❌ **Common** → Backend or Frontend (FORBIDDEN)\n- ❌ **Frontend** → Backend (FORBIDDEN)\n\nSee [dependency-linter.md](/checkstack/developer-guide/tooling/dependency-linter/) for details.\n\n## Plugin Lifecycle\n\n### Backend Plugin Lifecycle\n\nBackend plugins use a **two-phase initialization** to ensure cross-plugin communication works correctly:\n\n```mermaid\ngraph TD\n A[Plugin Discovery] --> B[Load Plugin Module]\n B --> C[Create Plugin Schema]\n C --> D[Run Migrations]\n D --> E[Call register function]\n E --> F[Register Access Rules]\n E --> G[Register Services]\n E --> H[Register Extension Points]\n E --> I[Register Init Function]\n \n subgraph \"Phase 2: Init\"\n I --> J[Resolve Dependencies]\n J --> K[Call init - Register routers]\n end\n \n subgraph \"Phase 3: After Plugins Ready\"\n K --> L[All Plugins Initialized]\n L --> M[Call afterPluginsReady]\n M --> N[Cross-plugin RPC + Hooks]\n end\n \n N --> O[Plugin Active]\n```\n\n> **Key Point:** The `init` function registers routers and services. The `afterPluginsReady` callback runs after ALL plugins have initialized, making it safe to:\n> - Call other plugins via RPC\n> - Subscribe to hooks (`onHook`)\n> - Emit hooks (`emitHook`)\n\n### Frontend Plugin Lifecycle\n\n```mermaid\ngraph TD\n A[Plugin Discovery] --> B[Load Plugin Module]\n B --> C[Register APIs]\n C --> D[Register Routes]\n D --> E[Register Nav Items]\n E --> F[Register Extensions]\n F --> G[Plugin Active]\n```\n\n## Database Isolation\n\nEach backend plugin gets its own **isolated PostgreSQL schema**:\n\n```\nDatabase: checkstack\n├── Schema: public (core only)\n├── Schema: plugin_catalog-backend\n├── Schema: plugin_auth-backend\n└── Schema: plugin_healthcheck-backend\n```\n\n### Benefits\n\n- **Namespace isolation**: No table name conflicts\n- **Independent migrations**: Each plugin manages its own schema\n- **Security**: Plugins can't access each other's data directly\n- **Scalability**: Easy to split into separate databases later\n\nSee [Drizzle Schema Isolation](/checkstack/developer-guide/backend/drizzle-schema/) for implementation details.\n\n## Extension Points\n\nExtension points enable plugins to provide implementations for core functionality:\n\n### Backend Extension Points\n\n- **HealthCheckStrategy**: Implement custom health check methods\n- **ExporterStrategy**: Export metrics and data in various formats\n- **NotificationStrategy**: Send notifications via different channels\n- **AuthenticationStrategy**: Integrate authentication providers\n\n### Frontend Extension Points\n\n- **Slots**: Inject UI components into predefined locations\n- **Routes**: Add new pages to the application\n- **APIs**: Provide client-side services\n\nSee [Extension Points](/checkstack/developer-guide/frontend/extension-points/) for detailed documentation.\n\n## Configuration Management\n\nPlugins use **versioned configurations** to support backward compatibility:\n\n```typescript\ninterface VersionedConfig<T> {\n version: number;\n pluginId: string;\n data: T;\n migratedAt?: Date;\n originalVersion?: number;\n}\n```\n\nThis enables:\n- Schema evolution without breaking existing configs\n- Automatic migration of old configurations\n- Rollback support\n\nSee [versioned-configs.md](/checkstack/developer-guide/backend/versioned-configs/) for details.\n\n## Communication Patterns\n\n### Frontend ↔ Backend\n\n```mermaid\nsequenceDiagram\n participant F as Frontend Plugin\n participant FA as Fetch API\n participant R as Router\n participant B as Backend Plugin\n \n F->>FA: Request with credentials\n FA->>R: HTTPS + JWT\n R->>R: Validate JWT\n R->>R: Check access\n R->>B: Route to plugin\n B->>R: Response\n R->>FA: JSON response\n FA->>F: Typed data\n```\n\n### Backend ↔ Backend\n\n```mermaid\nsequenceDiagram\n participant P1 as Plugin A\n participant S as Service Registry\n participant P2 as Plugin B\n \n P1->>S: Get service reference\n S->>P1: Service instance\n P1->>P2: Call via HTTPS + JWT\n P2->>P2: Validate service token\n P2->>P1: Response\n```\n\n### WebSocket (Plugin-Registered)\n\nPlugins can register custom WebSocket endpoints via the **WebSocket Route Registry**. All routes are automatically namespaced by plugin ID to prevent collisions:\n\n```typescript\n// In satellite-backend's afterPluginsReady:\nwsRegistry.register(\"/\", wsHandler);\n// → Available at /api/ws/satellite\n\n// Plugins can register sub-paths too:\nwsRegistry.register(\"/events\", eventsHandler);\n// → Available at /api/ws/{pluginId}/events\n```\n\nThe registry uses the same **scoped factory pattern** as RPC and health check registries — plugins never provide their ID manually.\n\n> **Note:** The signal/realtime WebSocket (`/api/signals/ws`) uses Bun's native pub/sub and is handled separately from the registry.\n\n## Access System\n\nAccess rules are defined in common packages and registered by backend plugins:\n\n```typescript\n// In catalog-common\nexport const access = {\n entityRead: {\n id: \"entity.read\",\n description: \"Read Systems and Groups\",\n },\n} satisfies Record<string, AccessRule>;\n\n// In catalog-backend\nenv.registerAccessRules(accessRuleList);\n\n// In catalog-frontend\nconst canRead = accessApi.useAccess(access.entityRead.id);\n```\n\nThe core automatically prefixes access rules with the plugin ID: `catalog.entity.read`\n\n## Technology Stack\n\n### Backend\n- **Runtime**: Bun\n- **Framework**: Hono (HTTP routing)\n- **Database**: PostgreSQL + Drizzle ORM\n- **Validation**: Zod\n- **Testing**: Bun test runner\n\n### Frontend\n- **Framework**: React\n- **Routing**: React Router DOM\n- **UI**: ShadCN + Tailwind CSS\n- **Build**: Vite\n- **Testing**: Playwright (E2E)\n\n## Deployment Options\n\n### Monolith (Default)\n\nAll plugins run in a single process:\n```bash\nbun run dev\n```\n\n### Microservices\n\nEach plugin can run independently:\n```bash\n# Terminal 1\nbun run dev:backend --plugins=catalog-backend\n\n# Terminal 2\nbun run dev:backend --plugins=auth-backend\n\n# Terminal 3\nbun run dev:frontend\n```\n\n### Hybrid\n\nMix and match based on scaling needs:\n- Core + frequently-used plugins in monolith\n- Resource-intensive plugins as separate services\n- Geographic distribution for compliance\n\n## Next Steps\n\n- [Packages vs Plugins Architecture](/checkstack/developer-guide/architecture/packages-vs-plugins/)\n- [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/)\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/)\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/)\n- [Common Plugin Guidelines](/checkstack/developer-guide/common/plugins/)\n- [Extension Points](/checkstack/developer-guide/frontend/extension-points/)\n- [Versioned Configurations](/checkstack/developer-guide/backend/versioned-configs/)\n- [Health and Readiness Probes](/checkstack/user-guide/reference/health-probes/)\n- [Contributing Guide](/checkstack/developer-guide/getting-started/contributing/)",
|
|
321
322
|
"truncated": false
|
|
322
323
|
},
|
|
323
324
|
{
|
|
@@ -1802,6 +1803,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
1802
1803
|
"headings": [
|
|
1803
1804
|
"Prerequisites",
|
|
1804
1805
|
"Bootstrap a new plugin repo",
|
|
1806
|
+
"Keep the tooling current",
|
|
1805
1807
|
"Core plugin dependencies are co-loaded",
|
|
1806
1808
|
"Frontend plugins",
|
|
1807
1809
|
"What `bun run dev` does",
|
|
@@ -1814,7 +1816,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
1814
1816
|
"Fallback: workspace fork",
|
|
1815
1817
|
"See also"
|
|
1816
1818
|
],
|
|
1817
|
-
"content": "Develop a Checkstack plugin from its own repo. No monorepo checkout. No\nupload loop. No Docker bind-mount tricks.\n\n```bash\nbunx @checkstack/dev-server\n```\n\n`@checkstack/dev-server` is the published npm package that ships the\ndev server; it exposes a `checkstack-dev` binary so once you've added\nit as a devDependency, your `package.json` can wire `\"dev\":\n\"checkstack-dev\"` and you run `bun run dev` from then on (see the\nbootstrap section below). The `bunx @checkstack/dev-server` form is for\na one-shot try before any install.\n\nThe command boots the same backend code path Checkstack uses\nin production, with two well-defined dev overrides:\n\n- **Filesystem plugin discovery is skipped.** Only your plugin loads —\n nothing else from a `core/` or `plugins/` directory.\n- **Auth is synthetic.** Every access rule the platform registers is\n auto-granted to a `dev-user` identity. No login flow.\n\nYour plugin's `register()` runs against a real `PluginManager`, real\n`coreServices.*`, real oRPC routing, real Drizzle migrations. The boot\ncode is the *exact* same module that ships in the production Docker image\n— there is no parallel \"dev backend\" stack to drift from.\n\nWhen you save a file under `./src`, the backend restarts. Bun cold-starts\nin well under a second for a single plugin, so the loop stays tight.\n\n## Prerequisites\n\n1. **Bun installed locally** (`curl -fsSL https://bun.sh/install | bash`).\n2. **A running Postgres** reachable at `localhost:5432`. The dev server\n doesn't ship one — it expects one. The smallest setup:\n\n ```bash\n docker run --name checkstack-dev-pg -d -p 5432:5432 \\\n -e POSTGRES_USER=checkstack \\\n -e POSTGRES_PASSWORD=checkstack \\\n -e POSTGRES_DB=checkstack \\\n postgres:16-alpine\n ```\n\n To point at a different Postgres, pass `--db-url` or set\n `DATABASE_URL`.\n\n3. **A valid plugin `package.json`.** The dev server validates the same\n `installPackageMetadataSchema` the runtime install pipeline uses, so\n missing or malformed fields fail fast before anything boots. See\n [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/#anatomy-of-an-installable-plugin)\n for required fields.\n\n## Bootstrap a new plugin repo\n\nUse `create-checkstack-plugin` to scaffold a complete standalone workspace\n(a `common` contract package, a `backend` implementing it, and a `frontend`\nconsuming it) in one command:\n\n```bash\nbunx create-checkstack-plugin widget\n# or with bun create:\nbun create checkstack-plugin widget\n```\n\nYou will be prompted for an npm scope (e.g. `acme` for `@acme/widget-*`).\nTo accept defaults without prompts, pass `--yes`:\n\n```bash\nbunx create-checkstack-plugin widget --scope acme --yes\n```\n\nThe scaffolder resolves the concrete published `@checkstack/*` versions from\nthe registry at scaffold time (each package independently - they are 0.x and\nnot lockstepped) and writes them as caret ranges in the generated\n`package.json` files. It then runs `git init` in the new directory.\n\nThe result is a Bun workspace ready to boot:\n\n```\nwidget/\n package.json # private root: workspaces [\"packages/*\"], forwarding scripts\n tsconfig.json\n eslint.config.js\n .gitignore\n README.md\n packages/\n widget-common/ # shared contract, Zod schemas, access rules\n widget-backend/ # Drizzle schema, oRPC router, example CRUD procedures\n widget-frontend/ # React page consuming the typed client\n```\n\nThen:\n\n```bash\ncd widget\nbun install\nbun run dev\n# backend: http://localhost:3000\n# frontend: http://localhost:5173\n```\n\nThe backend serves the example `getItems` / `createItem` / ... procedures\nimmediately - a `drizzle/0000_init` migration runs automatically on boot to\ncreate the `items` table. No Redis, no queue, no extra config.\n\nTest the API with curl (auth is synthetic in dev mode):\n\n```bash\ncurl -X POST http://localhost:3000/api/widget/getItems \\\n -H 'content-type: application/json' \\\n -d '{\"json\": {}}'\n# → {\"json\": []}\n```\n\nOpen `http://localhost:5173` to see the frontend list page.\n\n> [!NOTE]\n> **Frontend HMR works from a published install.** The Vite dev server\n> resolves `@checkstack/frontend` (which ships as a dependency of\n> `@checkstack/dev-server`) and the Vite React plugin from the dev server's\n> own install location, so a plugin scaffolded and `bun install`ed from the\n> registry gets HMR without depending on `@checkstack/frontend` directly. A\n> `-frontend` sibling that lives in your workspace (the standalone scaffold\n> layout) is picked up by scanning sibling package directories, so it does\n> not need to be an installed dependency either. The one prerequisite is the\n> obvious one: run `bun install` so your plugin's dev dependencies (including\n> `@checkstack/dev-server`) are present before `bun run dev`.\n\n> [!TIP]\n> **Tailwind styling works in dev from a published install.** Your own\n> custom Tailwind utility classes are compiled into the dev CSS, not just\n> the built-in `@checkstack/ui` components. `@checkstack/frontend` ships the\n> Tailwind toolchain (`tailwindcss`, `autoprefixer`, `tailwindcss-animate`)\n> as runtime dependencies and exports a shared theme preset at\n> `@checkstack/frontend/tailwind-preset`. The dev server applies that preset\n> and injects your plugin's own source globs (`<plugin>/src/**`) into\n> Tailwind's `content`, so classes you write in your `-frontend` components\n> render live with HMR. If you want to reuse the platform theme in your own\n> Tailwind config, add the preset:\n>\n> ```js\n> // tailwind.config.js\n> import checkstackPreset from \"@checkstack/frontend/tailwind-preset\";\n>\n> export default {\n> presets: [checkstackPreset],\n> content: [\"./src/**/*.{ts,tsx}\"],\n> };\n> ```\n\nOpen the URL. The Plugin Manager UI shows your plugin loaded; any\nprocedures it exposes are reachable at `/api/<pluginId>/*`.\n\n## Core plugin dependencies are co-loaded\n\nReal plugins almost always depend on platform plugins —\n`@checkstack/healthcheck-backend` for a health check strategy,\n`@checkstack/notification-backend` for a notification strategy,\n`@checkstack/catalog-backend` for a custom catalog kind, etc. The dev\ncommand walks your plugin's `package.json#dependencies` (recursively)\nand loads every `@checkstack/*-backend` package it finds alongside the\nplugin under dev. Without this, your plugin's `init()` would hit\nunregistered services and the boot would deadlock.\n\nTwo cases the resolver handles automatically:\n\n- **Transitive backend deps.** If your plugin depends on\n `@checkstack/notification-discord-backend`, which itself depends on\n `@checkstack/notification-backend`, both load.\n- **Auto-included dev providers.** When no queue or cache provider is\n in your dep graph (the common case for non-`queue-*` /\n non-`cache-*` plugins), the dev command auto-includes\n `@checkstack/queue-memory-backend` and\n `@checkstack/cache-memory-backend` so the platform's queue and cache\n services have a registered strategy. They're zero-config and fine\n for dev. Operators wire BullMQ / Redis / etc. in production.\n\nYou'll see a line like the following in the boot log:\n\n```\n📦 Co-loading 3 core plugin deps:\n @checkstack/healthcheck-backend, @checkstack/queue-memory-backend, @checkstack/cache-memory-backend\n```\n\nFrontend (`-frontend`) and tooling-type packages are not co-loaded as\nbackend plugins — they're resolved through their own paths (the Vite\ndev server for frontend, transitive type imports for common).\n\n## Frontend plugins\n\nWhen `package.json#checkstack.type === \"frontend\"` (or your `-backend`\ndeclares a `-frontend` sibling in `checkstack.bundle`), the dev command\nalso spawns a Vite dev server with HMR on\n[http://localhost:5173](http://localhost:5173). The Vite server proxies\n`/api` and `/assets/plugins` to the backend on :3000, so the SPA can\ntalk to the plugin you just registered.\n\nBehind the scenes, Vite serves `core/frontend`'s `dev-main.tsx` shell —\nthe same `App.tsx`, `loadPlugins()`, `ThemeProvider`, etc. that ship in\nproduction. Your plugin module is mounted via the\n`virtual:checkstack-dev-plugin` alias resolved at config time. Saving a\ncomponent in your plugin triggers React Fast Refresh in the browser —\nno full reload.\n\nFor pure backend plugins, the Vite server is skipped; only port 3000\nruns.\n\n## What `bun run dev` does\n\n```mermaid\nsequenceDiagram\n participant Dev as Plugin author\n participant DevServer as @checkstack/dev-server\n participant Backend as @checkstack/backend\n participant Watcher as fs.watch on ./src\n\n Dev->>DevServer: bun run dev (checkstack-dev)\n DevServer->>DevServer: validate package.json\n DevServer->>DevServer: resolve @checkstack/backend\n DevServer->>Backend: spawn `bun run <backend-entry>`<br/>env: CHECKSTACK_DEV_PLUGIN_PATH=cwd<br/>env: CHECKSTACK_DEV_AUTH=true\n Backend->>Backend: skipDiscovery=true; load plugin manually\n Backend->>Backend: register dev auth (auto-grants every rule)\n Backend->>Dev: HTTP 200 on http://localhost:3000\n Watcher-->>DevServer: file change in ./src\n DevServer->>Backend: SIGTERM\n DevServer->>Backend: respawn\n```\n\nTwo env vars do the work. Both are inert in production — `core/backend`\nrefuses `CHECKSTACK_DEV_AUTH=true` when `NODE_ENV=production` and ignores\n`CHECKSTACK_DEV_PLUGIN_PATH` if unset.\n\n## Command-line flags\n\n```\nbunx @checkstack/dev-server --help\n```\n\n(After installing `@checkstack/dev-server` as a devDependency, the\nbinary is on the local `node_modules/.bin` path, so `bun run dev --\n--help` or `checkstack-dev --help` both work too.)\n\n| Flag | Default | Notes |\n|------------------------|--------------------------------------------------------------------------|----------------------------------------------------|\n| `--cwd <dir>` | `process.cwd()` | Plugin directory. |\n| `--port <num>` | `3000` (or `$PORT`) | Backend HTTP port. |\n| `--frontend-port <num>`| `5173` (or `$FRONTEND_PORT`) | Vite dev port. Only used when the plugin (or a bundle sibling) is a `-frontend`. |\n| `--db-url <url>` | `$DATABASE_URL` or `postgresql://checkstack:checkstack@localhost:5432/checkstack` | Postgres URL for core + plugin migrations. |\n| `--no-watch` | watching enabled | Disable auto-restart on file changes. |\n\n## Hitting your plugin\n\nAuth is bypassed, so any browser tab or curl invocation against\n`http://localhost:3000/api/<pluginId>/...` authorizes as the dev user\nwith full access. To test from curl:\n\n```bash\ncurl -X POST http://localhost:3000/api/widget/listWidgets \\\n -H 'content-type: application/json' \\\n -d '{\"json\": {}}'\n```\n\noRPC's `RPCHandler` accepts JSON envelopes; the\n[`@orpc/client`](https://orpc.unnoq.com/) packages produce them\nautomatically if you wire a typed client.\n\n## Logs\n\nThe dev server pipes the backend's `stdout` / `stderr` to your terminal\nvia `stdio: \"inherit\"`. You see exactly what production logs would show\n— Winston-formatted lines including request/response logs, plugin\nlifecycle events, and any RPC error stack traces.\n\n## Database state\n\nMigrations run against the live Postgres on every boot. The `plugins`\ntable tracks your plugin (the dev server also passes through the install\nevent recorder), so you can hit Plugin Manager → Events to see\nregister/init traces.\n\nTo wipe state and start fresh, drop and recreate the database:\n\n```bash\ndocker exec -it checkstack-dev-pg \\\n psql -U checkstack -c \"DROP DATABASE checkstack;\"\ndocker exec -it checkstack-dev-pg \\\n psql -U checkstack -d postgres -c \"CREATE DATABASE checkstack;\"\n```\n\n## Validation against production\n\nBefore tagging a release, validate that the runtime install path —\nmetadata schema, compatibility check, install scripts handling — is\nhappy with what you've built:\n\n```bash\nbunx @checkstack/scripts plugin-pack --validate-only\n```\n\nFor a final smoke test, pack and install via the Plugin Manager UI of a\nreal Checkstack deployment (or the same dev server's UI). The dev server\nloads your plugin via `manualPlugins`; the install path loads it from a\ntarball. They exercise the same `register()` / `init()` hooks but not\nthe same install code path, so the pack-and-install run is a useful\nfinal check.\n\n## Troubleshooting\n\n**`Could not locate @checkstack/backend`**\n\nMake sure `@checkstack/dev-server` is in your devDependencies, and that\nthe platform package matching your plugin's type is too — `@checkstack/backend`\nfor a backend plugin, `@checkstack/frontend` for a frontend plugin (or\nboth for a multi-package plugin that ships frontend + backend together).\nThe dev server resolves them from your plugin's own `node_modules` (so\nthe version your plugin pins is what runs). Run `bun install` again.\n\n**Port 3000 in use**\n\nPass `--port 4000` or set `PORT=4000` in your environment.\n\n**Postgres connection refused**\n\nThe dev server expects Postgres on `localhost:5432`. Either start the\nDocker container above or pass `--db-url` pointing at a reachable\ninstance.\n\n**`Plugin package.json failed install-time validation`**\n\nAdd the missing field. The error lists the exact path\n(`checkstack.pluginId`, `description`, etc.). The validator is the same\nZod schema the runtime install uses — see the\n[required fields table](/checkstack/developer-guide/architecture/plugin-distribution/#required-packagejson-fields).\n\n**Restart loop on every save with no actual change**\n\nEditor temp files (Vim swap files, IDE autosave artifacts) can trigger\nspurious events. The dev server already filters dotfiles and `~`-suffixed\nfiles. If your editor uses a different pattern, file an issue with the\nfilename so we can extend the filter.\n\n## Fallback: workspace fork\n\nFor deep core debugging — stepping through `core/backend` while a plugin\nruns — checking out the upstream Checkstack repo and dropping your\nplugin into `plugins/` still works as it always did:\n\n```bash\ngit clone https://github.com/enyineer/checkstack\ncd checkstack\ngit -C plugins/ clone <your-plugin-repo>\nbun install\nbun run typecheck:references:generate\nbun run dev\n```\n\nUse this when the dev server isn't enough — almost always when you're\ncontributing a core change *alongside* a plugin change.\n\n## See also\n\n- [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/) —\n how to ship your plugin once it's working\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/) — writing the\n plugin's code itself\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/)\n- [Common Plugin Guidelines](/checkstack/developer-guide/common/plugins/)",
|
|
1819
|
+
"content": "Develop a Checkstack plugin from its own repo. No monorepo checkout. No\nupload loop. No Docker bind-mount tricks.\n\n```bash\nbunx @checkstack/dev-server@latest\n```\n\n`@checkstack/dev-server` is the published npm package that ships the\ndev server; it exposes a `checkstack-dev` binary so once you've added\nit as a devDependency, your `package.json` can wire `\"dev\":\n\"checkstack-dev\"` and you run `bun run dev` from then on (see the\nbootstrap section below). The `bunx @checkstack/dev-server@latest` form\nis for a one-shot try before any install - pin `@latest` so Bun does not\nrun a stale cached copy (see [Keep the tooling current](#keep-the-tooling-current)).\n\nThe command boots the same backend code path Checkstack uses\nin production, with two well-defined dev overrides:\n\n- **Filesystem plugin discovery is skipped.** Only your plugin loads —\n nothing else from a `core/` or `plugins/` directory.\n- **Auth is synthetic.** Every access rule the platform registers is\n auto-granted to a `dev-user` identity. No login flow.\n\nYour plugin's `register()` runs against a real `PluginManager`, real\n`coreServices.*`, real oRPC routing, real Drizzle migrations. The boot\ncode is the *exact* same module that ships in the production Docker image\n— there is no parallel \"dev backend\" stack to drift from.\n\nWhen you save a file under `./src`, the backend restarts. Bun cold-starts\nin well under a second for a single plugin, so the loop stays tight.\n\n## Prerequisites\n\n1. **Bun installed locally** (`curl -fsSL https://bun.sh/install | bash`).\n2. **A running Postgres** reachable at `localhost:5432`. The dev server\n doesn't ship one — it expects one. The smallest setup:\n\n ```bash\n docker run --name checkstack-dev-pg -d -p 5432:5432 \\\n -e POSTGRES_USER=checkstack \\\n -e POSTGRES_PASSWORD=checkstack \\\n -e POSTGRES_DB=checkstack \\\n postgres:16-alpine\n ```\n\n To point at a different Postgres, pass `--db-url` or set\n `DATABASE_URL`.\n\n3. **A valid plugin `package.json`.** The dev server validates the same\n `installPackageMetadataSchema` the runtime install pipeline uses, so\n missing or malformed fields fail fast before anything boots. See\n [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/#anatomy-of-an-installable-plugin)\n for required fields.\n\n## Bootstrap a new plugin repo\n\nUse `create-checkstack-plugin` to scaffold a complete standalone workspace\n(a `common` contract package, a `backend` implementing it, and a `frontend`\nconsuming it) in one command:\n\n```bash\nbunx create-checkstack-plugin@latest widget\n# or with bun create:\nbun create checkstack-plugin@latest widget\n```\n\nAlways pin `@latest`. Bun caches the package per `name@version` and may serve a\nstale copy otherwise - see [Keep the tooling current](#keep-the-tooling-current).\n\nYou will be prompted for an npm scope (e.g. `acme` for `@acme/widget-*`).\nTo accept defaults without prompts, pass `--yes`:\n\n```bash\nbunx create-checkstack-plugin@latest widget --scope acme --yes\n```\n\nThe scaffolder resolves the concrete published `@checkstack/*` versions from\nthe registry at scaffold time (each package independently - they are 0.x and\nnot lockstepped) and writes them as caret ranges in the generated\n`package.json` files. It then runs `git init` in the new directory.\n\n## Keep the tooling current\n\n`create-checkstack-plugin`, `@checkstack/dev-server`, and `@checkstack/scripts`\nare published to npm and run through Bun's cache. Understanding how that cache\nbehaves saves you from a confusing \"I published a fix but the old behaviour is\nstill running\" loop.\n\n- **Resolving the latest version.** A bare `bunx create-checkstack-plugin`\n resolves the `latest` dist-tag from npm on each run - it is *not* pinned to\n the first version you happened to cache. But Bun's view of \"what is latest\" is\n driven by a cached registry manifest, and Bun deliberately ignores the\n `Age` header, so it \"may be about 5 minutes out of date to receive the latest\n package version metadata from npm\". Right after a publish, expect up to a\n ~5-minute window before a fresh resolve sees it.\n- **Package contents are cached by version.** Downloaded tarballs live at\n `~/.bun/install/cache/<name>@<version>` and are content-addressed by version\n with no time-based expiry. A new version is a new cache key (so you get it\n automatically), but **re-publishing the *same* version with different\n contents is never re-fetched** - always bump the version.\n- **Pin `@latest` in example/one-off commands.** Writing\n `bunx create-checkstack-plugin@latest` (rather than the bare name) makes the\n intent explicit and forces resolution of the `latest` dist-tag.\n\nTo force a refresh immediately - e.g. you just published and do not want to wait\nout the manifest window, or you need to bust a same-version tarball:\n\n```bash\nbun pm cache rm # clear Bun's global install cache, then re-run\n# or, more surgically, target a single package:\nrm -rf ~/.bun/install/cache/create-checkstack-plugin@*\n```\n\n> [!NOTE]\n> A scaffolded plugin's own `package.json` scripts call the **installed binaries**\n> (`\"pack\": \"checkstack-scripts plugin-pack\"`, `\"dev\": \"checkstack-dev\"`), not\n> `bunx`. Those resolve from `node_modules/.bin` against the pinned\n> `@checkstack/scripts` / `@checkstack/dev-server` devDependencies, so a committed\n> script always runs the version your lockfile installed - never a cache-resolved\n> \"latest\". Use `@latest` only for the one-shot `bunx` commands above.\n\nThe result is a Bun workspace ready to boot:\n\n```\nwidget/\n package.json # private root: workspaces [\"packages/*\"], forwarding scripts\n tsconfig.json\n eslint.config.js\n .gitignore\n README.md\n packages/\n widget-common/ # shared contract, Zod schemas, access rules\n widget-backend/ # Drizzle schema, oRPC router, example CRUD procedures\n widget-frontend/ # React page consuming the typed client\n```\n\nThen:\n\n```bash\ncd widget\nbun install\nbun run dev\n# backend: http://localhost:3000\n# frontend: http://localhost:5173\n```\n\nThe backend serves the example `getItems` / `createItem` / ... procedures\nimmediately - a `drizzle/0000_init` migration runs automatically on boot to\ncreate the `items` table. No Redis, no queue, no extra config.\n\nTest the API with curl (auth is synthetic in dev mode):\n\n```bash\ncurl -X POST http://localhost:3000/api/widget/getItems \\\n -H 'content-type: application/json' \\\n -d '{\"json\": {}}'\n# → {\"json\": []}\n```\n\nOpen `http://localhost:5173` to see the frontend list page.\n\n> [!NOTE]\n> **Frontend HMR works from a published install.** The Vite dev server\n> resolves `@checkstack/frontend` (which ships as a dependency of\n> `@checkstack/dev-server`) and the Vite React plugin from the dev server's\n> own install location, so a plugin scaffolded and `bun install`ed from the\n> registry gets HMR without depending on `@checkstack/frontend` directly. A\n> `-frontend` sibling that lives in your workspace (the standalone scaffold\n> layout) is picked up by scanning sibling package directories, so it does\n> not need to be an installed dependency either. The one prerequisite is the\n> obvious one: run `bun install` so your plugin's dev dependencies (including\n> `@checkstack/dev-server`) are present before `bun run dev`.\n\n> [!TIP]\n> **Tailwind styling works in dev from a published install.** Your own\n> custom Tailwind utility classes are compiled into the dev CSS, not just\n> the built-in `@checkstack/ui` components. `@checkstack/frontend` ships the\n> Tailwind toolchain (`tailwindcss`, `autoprefixer`, `tailwindcss-animate`)\n> as runtime dependencies and exports a shared theme preset at\n> `@checkstack/frontend/tailwind-preset`. The dev server applies that preset\n> and injects your plugin's own source globs (`<plugin>/src/**`) into\n> Tailwind's `content`, so classes you write in your `-frontend` components\n> render live with HMR. If you want to reuse the platform theme in your own\n> Tailwind config, add the preset:\n>\n> ```js\n> // tailwind.config.js\n> import checkstackPreset from \"@checkstack/frontend/tailwind-preset\";\n>\n> export default {\n> presets: [checkstackPreset],\n> content: [\"./src/**/*.{ts,tsx}\"],\n> };\n> ```\n\nOpen the URL. The Plugin Manager UI shows your plugin loaded; any\nprocedures it exposes are reachable at `/api/<pluginId>/*`.\n\n## Core plugin dependencies are co-loaded\n\nReal plugins almost always depend on platform plugins —\n`@checkstack/healthcheck-backend` for a health check strategy,\n`@checkstack/notification-backend` for a notification strategy,\n`@checkstack/catalog-backend` for a custom catalog kind, etc. The dev\ncommand walks your plugin's `package.json#dependencies` (recursively)\nand loads every `@checkstack/*-backend` package it finds alongside the\nplugin under dev. Without this, your plugin's `init()` would hit\nunregistered services and the boot would deadlock.\n\nTwo cases the resolver handles automatically:\n\n- **Transitive backend deps.** If your plugin depends on\n `@checkstack/notification-discord-backend`, which itself depends on\n `@checkstack/notification-backend`, both load.\n- **Auto-included dev providers.** When no queue or cache provider is\n in your dep graph (the common case for non-`queue-*` /\n non-`cache-*` plugins), the dev command auto-includes\n `@checkstack/queue-memory-backend` and\n `@checkstack/cache-memory-backend` so the platform's queue and cache\n services have a registered strategy. They're zero-config and fine\n for dev. Operators wire BullMQ / Redis / etc. in production.\n\nYou'll see a line like the following in the boot log:\n\n```\n📦 Co-loading 3 core plugin deps:\n @checkstack/healthcheck-backend, @checkstack/queue-memory-backend, @checkstack/cache-memory-backend\n```\n\nFrontend (`-frontend`) and tooling-type packages are not co-loaded as\nbackend plugins — they're resolved through their own paths (the Vite\ndev server for frontend, transitive type imports for common).\n\n## Frontend plugins\n\nWhen `package.json#checkstack.type === \"frontend\"` (or your `-backend`\ndeclares a `-frontend` sibling in `checkstack.bundle`), the dev command\nalso spawns a Vite dev server with HMR on\n[http://localhost:5173](http://localhost:5173). The Vite server proxies\n`/api` and `/assets/plugins` to the backend on :3000, so the SPA can\ntalk to the plugin you just registered.\n\nBehind the scenes, Vite serves `core/frontend`'s `dev-main.tsx` shell —\nthe same `App.tsx`, `loadPlugins()`, `ThemeProvider`, etc. that ship in\nproduction. Your plugin module is mounted via the\n`virtual:checkstack-dev-plugin` alias resolved at config time. Saving a\ncomponent in your plugin triggers React Fast Refresh in the browser —\nno full reload.\n\nFor pure backend plugins, the Vite server is skipped; only port 3000\nruns.\n\n> [!NOTE]\n> `@checkstack/ui`'s Monaco `CodeEditor` works in standalone `bun run dev`.\n> Because `@checkstack/ui` is a pre-bundled npm dependency in a standalone\n> install, Vite can't process the Monaco language workers it imports via\n> `?worker&url` during dependency pre-bundling. The dev server therefore\n> pre-builds those workers once (cached under\n> `node_modules/.cache/checkstack-dev-monaco`) and serves them, so the editor\n> renders the same as in the monorepo. The first `bun run dev` after installing\n> or upgrading the editor packages takes a little longer while the workers\n> build; subsequent runs reuse the cache.\n\n## What `bun run dev` does\n\n```mermaid\nsequenceDiagram\n participant Dev as Plugin author\n participant DevServer as @checkstack/dev-server\n participant Backend as @checkstack/backend\n participant Watcher as fs.watch on ./src\n\n Dev->>DevServer: bun run dev (checkstack-dev)\n DevServer->>DevServer: validate package.json\n DevServer->>DevServer: resolve @checkstack/backend\n DevServer->>Backend: spawn `bun run <backend-entry>`<br/>env: CHECKSTACK_DEV_PLUGIN_PATH=cwd<br/>env: CHECKSTACK_DEV_AUTH=true\n Backend->>Backend: skipDiscovery=true; load plugin manually\n Backend->>Backend: register dev auth (auto-grants every rule)\n Backend->>Dev: HTTP 200 on http://localhost:3000\n Watcher-->>DevServer: file change in ./src\n DevServer->>Backend: SIGTERM\n DevServer->>Backend: respawn\n```\n\nTwo env vars do the work. Both are inert in production — `core/backend`\nrefuses `CHECKSTACK_DEV_AUTH=true` when `NODE_ENV=production` and ignores\n`CHECKSTACK_DEV_PLUGIN_PATH` if unset.\n\n## Command-line flags\n\n```\nbunx @checkstack/dev-server@latest --help\n```\n\n(After installing `@checkstack/dev-server` as a devDependency, the\nbinary is on the local `node_modules/.bin` path, so `bun run dev --\n--help` or `checkstack-dev --help` both work too.)\n\n| Flag | Default | Notes |\n|------------------------|--------------------------------------------------------------------------|----------------------------------------------------|\n| `--cwd <dir>` | `process.cwd()` | Plugin directory. |\n| `--port <num>` | `3000` (or `$PORT`) | Backend HTTP port. |\n| `--frontend-port <num>`| `5173` (or `$FRONTEND_PORT`) | Vite dev port. Only used when the plugin (or a bundle sibling) is a `-frontend`. |\n| `--db-url <url>` | `$DATABASE_URL` or `postgresql://checkstack:checkstack@localhost:5432/checkstack` | Postgres URL for core + plugin migrations. |\n| `--no-watch` | watching enabled | Disable auto-restart on file changes. |\n\n## Hitting your plugin\n\nAuth is bypassed, so any browser tab or curl invocation against\n`http://localhost:3000/api/<pluginId>/...` authorizes as the dev user\nwith full access. To test from curl:\n\n```bash\ncurl -X POST http://localhost:3000/api/widget/listWidgets \\\n -H 'content-type: application/json' \\\n -d '{\"json\": {}}'\n```\n\noRPC's `RPCHandler` accepts JSON envelopes; the\n[`@orpc/client`](https://orpc.unnoq.com/) packages produce them\nautomatically if you wire a typed client.\n\n## Logs\n\nThe dev server pipes the backend's `stdout` / `stderr` to your terminal\nvia `stdio: \"inherit\"`. You see exactly what production logs would show\n— Winston-formatted lines including request/response logs, plugin\nlifecycle events, and any RPC error stack traces.\n\n## Database state\n\nMigrations run against the live Postgres on every boot. The `plugins`\ntable tracks your plugin (the dev server also passes through the install\nevent recorder), so you can hit Plugin Manager → Events to see\nregister/init traces.\n\nTo wipe state and start fresh, drop and recreate the database:\n\n```bash\ndocker exec -it checkstack-dev-pg \\\n psql -U checkstack -c \"DROP DATABASE checkstack;\"\ndocker exec -it checkstack-dev-pg \\\n psql -U checkstack -d postgres -c \"CREATE DATABASE checkstack;\"\n```\n\n## Validation against production\n\nBefore tagging a release, validate that the runtime install path —\nmetadata schema, compatibility check, install scripts handling — is\nhappy with what you've built:\n\n```bash\nbunx @checkstack/scripts@latest plugin-pack --validate-only\n```\n\nFor a final smoke test, pack and install via the Plugin Manager UI of a\nreal Checkstack deployment (or the same dev server's UI). The dev server\nloads your plugin via `manualPlugins`; the install path loads it from a\ntarball. They exercise the same `register()` / `init()` hooks but not\nthe same install code path, so the pack-and-install run is a useful\nfinal check.\n\n## Troubleshooting\n\n**`Could not locate @checkstack/backend`**\n\nMake sure `@checkstack/dev-server` is in your devDependencies, and that\nthe platform package matching your plugin's type is too — `@checkstack/backend`\nfor a backend plugin, `@checkstack/frontend` for a frontend plugin (or\nboth for a multi-package plugin that ships frontend + backend together).\nThe dev server resolves them from your plugin's own `node_modules` (so\nthe version your plugin pins is what runs). Run `bun install` again.\n\n**Port 3000 in use**\n\nPass `--port 4000` or set `PORT=4000` in your environment.\n\n**Postgres connection refused**\n\nThe dev server expects Postgres on `localhost:5432`. Either start the\nDocker container above or pass `--db-url` pointing at a reachable\ninstance.\n\n**`Plugin package.json failed install-time validation`**\n\nAdd the missing field. The error lists the exact path\n(`checkstack.pluginId`, `description`, etc.). The validator is the same\nZod schema the runtime install uses — see the\n[required fields table](/checkstack/developer-guide/architecture/plugin-distribution/#required-packagejson-fields).\n\n**Restart loop on every save with no actual change**\n\nEditor temp files (Vim swap files, IDE autosave artifacts) can trigger\nspurious events. The dev server already filters dotfiles and `~`-suffixed\nfiles. If your editor uses a different pattern, file an issue with the\nfilename so we can extend the filter.\n\n## Fallback: workspace fork\n\nFor deep core debugging — stepping through `core/backend` while a plugin\nruns — checking out the upstream Checkstack repo and dropping your\nplugin into `plugins/` still works as it always did:\n\n```bash\ngit clone https://github.com/enyineer/checkstack\ncd checkstack\ngit -C plugins/ clone <your-plugin-repo>\nbun install\nbun run typecheck:references:generate\nbun run dev\n```\n\nUse this when the dev server isn't enough — almost always when you're\ncontributing a core change *alongside* a plugin change.\n\n## See also\n\n- [Plugin Distribution & Packing](/checkstack/developer-guide/architecture/plugin-distribution/) —\n how to ship your plugin once it's working\n- [Backend Plugin Development](/checkstack/developer-guide/backend/plugins/) — writing the\n plugin's code itself\n- [Frontend Plugin Development](/checkstack/developer-guide/frontend/plugins/)\n- [Common Plugin Guidelines](/checkstack/developer-guide/common/plugins/)",
|
|
1818
1820
|
"truncated": false
|
|
1819
1821
|
},
|
|
1820
1822
|
{
|
|
@@ -2573,7 +2575,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
2573
2575
|
"Database connection errors",
|
|
2574
2576
|
"Next Steps"
|
|
2575
2577
|
],
|
|
2576
|
-
"content": "This guide walks you through deploying Checkstack using Docker.\n\n## Prerequisites\n\n- Docker installed and running\n- PostgreSQL database (or use a managed service like Supabase, Neon, etc.)\n\n## Required Environment Variables\n\nCheckstack requires four environment variables to run:\n\n| Variable | Description | Requirements |\n|----------|-------------|--------------|\n| `DATABASE_URL` | PostgreSQL connection string | Valid Postgres URI |\n| `ENCRYPTION_MASTER_KEY` | Encrypts secrets in the database | 64 hex characters (32 bytes) |\n| `BETTER_AUTH_SECRET` | Signs session cookies and OAuth states | Minimum 32 characters |\n| `BASE_URL` | Exact URL used to access Checkstack in the browser | e.g. `http://192.168.1.123:3000` or `https://status.example.com` |\n\n## Generating Secrets\n\n### ENCRYPTION_MASTER_KEY\n\nGenerate a secure 32-byte key:\n\n```bash\n# Using Node.js\nnode -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"\n\n# Using OpenSSL\nopenssl rand -hex 32\n```\n\nThis produces a 64-character hexadecimal string (e.g., `a1b2c3d4e5f6...`).\n\n### BETTER_AUTH_SECRET\n\nGenerate a secure random string (minimum 32 characters):\n\n```bash\n# Using Node.js\nnode -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"\n\n# Using OpenSSL\nopenssl rand -base64 32\n```\n\n## Quick Start\n\n```bash\n# Pull the latest image\ndocker pull ghcr.io/enyineer/checkstack:latest\n\n# Run with required environment variables\ndocker run -d \\\n --name checkstack \\\n -e DATABASE_URL=\"postgresql://user:password@host:5432/checkstack\" \\\n -e ENCRYPTION_MASTER_KEY=\"<your-64-char-hex-key>\" \\\n -e BETTER_AUTH_SECRET=\"<your-32-char-secret>\" \\\n -e BASE_URL=\"http://192.168.1.123:3000\" \\\n -p 3000:3000 \\\n ghcr.io/enyineer/checkstack:latest\n```\n\n## Docker Compose (Recommended)\n\nThe Checkstack repository includes a ready-to-use `docker-compose.yml` in the project root that runs both Checkstack and PostgreSQL:\n\n```bash\n# Clone the repository (or download just the docker-compose.yml)\ngit clone https://github.com/enyineer/checkstack.git\ncd checkstack\n\n# Create your .env file with required secrets\ncat > .env << EOF\nPOSTGRES_USER=checkstack\nPOSTGRES_PASSWORD=checkstack\nPOSTGRES_DB=checkstack\nENCRYPTION_MASTER_KEY=$(openssl rand -hex 32)\nBETTER_AUTH_SECRET=$(openssl rand -base64 32)\nBASE_URL=\"http://192.168.1.123:3000\" # Must match exactly how you access Checkstack!\nEOF\n\n# Start everything\ndocker compose up -d\n```\n\n### Updating the Checkstack Image\n\nTo update to a newer version:\n\n```bash\n# Pull the latest image\ndocker compose pull\n\n# Recreate containers with the new image\ndocker compose up -d\n```\n\n> [!TIP]\n> You can also pin to a specific version by editing the `image:` line in `docker-compose.yml`:\n> ```yaml\n> image: ghcr.io/enyineer/checkstack:<version>\n> ```\n\n## Quick Start (Single Container)\n\nIf you already have a PostgreSQL database, you can run Checkstack as a single container:\n\n```bash\ndocker run -d \\\n --name checkstack \\\n -e DATABASE_URL=\"postgresql://user:password@host:5432/checkstack\" \\\n -e ENCRYPTION_MASTER_KEY=\"<your-64-char-hex-key>\" \\\n -e BETTER_AUTH_SECRET=\"<your-32-char-secret>\" \\\n -e BASE_URL=\"http://192.168.1.123:3000\" \\\n -p 3000:3000 \\\n ghcr.io/enyineer/checkstack:latest\n```\n\n## Optional Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |\n| `INTERNAL_URL` | (falls back to `BASE_URL`) | Internal RPC URL for backend-to-backend calls. Set to K8s service name (e.g., `http://checkstack-service:3000`) for multi-pod load balancing. |\n\n## Onboarding flow\n\n> [!TIP]\n> After first start, you'll have to create your first admin user.\n>\n> Upon opening the page eg. at `http://localhost:3000` you'll be greeted with a signup form.\n\n## Health Check\n\nVerify Checkstack is running:\n\n```bash\ncurl http://localhost:3000/api/health\n```\n\n## Troubleshooting\n\n### \"ENCRYPTION_MASTER_KEY must be 32 bytes (64 hex characters)\"\n\nYour encryption key is not the correct length. Generate a new one using the commands above.\n\n### \"BETTER_AUTH_SECRET must be at least 32 characters\"\n\nYour auth secret is too short. Generate a longer one using the commands above.\n\n### Onboarding screen does not appear / app loads empty or very slowly\n\nThis is almost always caused by a wrong `BASE_URL`. When `BASE_URL` points to an incorrect or unreachable address, the frontend cannot reach the backend for session and onboarding checks, which causes it to silently show empty state.\n\n**Make sure `BASE_URL` matches the EXACT URL YOU put into the browser address bar (including possible LAN IPs or the domain):**\n\n```bash\n# Example (Docker on LAN):\nBASE_URL=http://192.168.1.123:3000\n\n# Example (Production):\nBASE_URL=https://status.example.com\n```\n\nYou can verify the value your container is using by checking the config endpoint:\n\n```bash\ncurl http://localhost:3000/api/config\n# Expected: {\"baseUrl\":\"http://localhost:3000\"}\n```\n\nIf `baseUrl` in the response points to port `5173` or any other wrong address, update `BASE_URL` in your `.env` file and recreate the container:\n\n```bash\ndocker compose up -d --force-recreate\n```\n\n### Database connection errors\n\n- Verify your `DATABASE_URL` is correct and the database is reachable\n- Ensure PostgreSQL is running and accepting connections\n- Check firewall rules allow connections between containers\n\n## Next Steps\n\n- [Configure authentication strategies](/checkstack/user-guide/reference/authentication-strategies/)\n- [Set up notification channels](/checkstack/developer-guide/backend/notifications/strategies/)\n- [Create your first health check](/checkstack/developer-guide/backend/healthchecks/strategies/)",
|
|
2578
|
+
"content": "This guide walks you through deploying Checkstack using Docker.\n\n## Prerequisites\n\n- Docker installed and running\n- PostgreSQL database (or use a managed service like Supabase, Neon, etc.)\n\n## Required Environment Variables\n\nCheckstack requires four environment variables to run:\n\n| Variable | Description | Requirements |\n|----------|-------------|--------------|\n| `DATABASE_URL` | PostgreSQL connection string | Valid Postgres URI |\n| `ENCRYPTION_MASTER_KEY` | Encrypts secrets in the database | 64 hex characters (32 bytes) |\n| `BETTER_AUTH_SECRET` | Signs session cookies and OAuth states | Minimum 32 characters |\n| `BASE_URL` | Exact URL used to access Checkstack in the browser | e.g. `http://192.168.1.123:3000` or `https://status.example.com` |\n\n## Generating Secrets\n\n### ENCRYPTION_MASTER_KEY\n\nGenerate a secure 32-byte key:\n\n```bash\n# Using Node.js\nnode -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"\n\n# Using OpenSSL\nopenssl rand -hex 32\n```\n\nThis produces a 64-character hexadecimal string (e.g., `a1b2c3d4e5f6...`).\n\n### BETTER_AUTH_SECRET\n\nGenerate a secure random string (minimum 32 characters):\n\n```bash\n# Using Node.js\nnode -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"\n\n# Using OpenSSL\nopenssl rand -base64 32\n```\n\n## Quick Start\n\n```bash\n# Pull the latest image\ndocker pull ghcr.io/enyineer/checkstack:latest\n\n# Run with required environment variables\ndocker run -d \\\n --name checkstack \\\n -e DATABASE_URL=\"postgresql://user:password@host:5432/checkstack\" \\\n -e ENCRYPTION_MASTER_KEY=\"<your-64-char-hex-key>\" \\\n -e BETTER_AUTH_SECRET=\"<your-32-char-secret>\" \\\n -e BASE_URL=\"http://192.168.1.123:3000\" \\\n -p 3000:3000 \\\n ghcr.io/enyineer/checkstack:latest\n```\n\n## Docker Compose (Recommended)\n\nThe Checkstack repository includes a ready-to-use `docker-compose.yml` in the project root that runs both Checkstack and PostgreSQL:\n\n```bash\n# Clone the repository (or download just the docker-compose.yml)\ngit clone https://github.com/enyineer/checkstack.git\ncd checkstack\n\n# Create your .env file with required secrets\ncat > .env << EOF\nPOSTGRES_USER=checkstack\nPOSTGRES_PASSWORD=checkstack\nPOSTGRES_DB=checkstack\nENCRYPTION_MASTER_KEY=$(openssl rand -hex 32)\nBETTER_AUTH_SECRET=$(openssl rand -base64 32)\nBASE_URL=\"http://192.168.1.123:3000\" # Must match exactly how you access Checkstack!\nEOF\n\n# Start everything\ndocker compose up -d\n```\n\n### Updating the Checkstack Image\n\nTo update to a newer version:\n\n```bash\n# Pull the latest image\ndocker compose pull\n\n# Recreate containers with the new image\ndocker compose up -d\n```\n\n> [!TIP]\n> You can also pin to a specific version by editing the `image:` line in `docker-compose.yml`:\n> ```yaml\n> image: ghcr.io/enyineer/checkstack:<version>\n> ```\n\n## Quick Start (Single Container)\n\nIf you already have a PostgreSQL database, you can run Checkstack as a single container:\n\n```bash\ndocker run -d \\\n --name checkstack \\\n -e DATABASE_URL=\"postgresql://user:password@host:5432/checkstack\" \\\n -e ENCRYPTION_MASTER_KEY=\"<your-64-char-hex-key>\" \\\n -e BETTER_AUTH_SECRET=\"<your-32-char-secret>\" \\\n -e BASE_URL=\"http://192.168.1.123:3000\" \\\n -p 3000:3000 \\\n ghcr.io/enyineer/checkstack:latest\n```\n\n## Optional Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |\n| `INTERNAL_URL` | (falls back to `BASE_URL`) | Internal RPC URL for backend-to-backend calls. Set to K8s service name (e.g., `http://checkstack-service:3000`) for multi-pod load balancing. |\n| `TZ` | (host default) | IANA timezone the AI assistant uses to interpret bare times (e.g. \"22:00\") when no browser timezone is available, such as automation AI Actions. In-app chat already uses each operator's own browser timezone. Set this to your deployment's local zone (e.g. `Europe/Berlin`) to override the fallback. See [Internal chat](/checkstack/developer-guide/ai/chat/#dates-and-timezones). |\n\n## Onboarding flow\n\n> [!TIP]\n> After first start, you'll have to create your first admin user.\n>\n> Upon opening the page eg. at `http://localhost:3000` you'll be greeted with a signup form.\n\n## Health Check\n\nVerify Checkstack is running:\n\n```bash\ncurl http://localhost:3000/api/health\n```\n\n## Troubleshooting\n\n### \"ENCRYPTION_MASTER_KEY must be 32 bytes (64 hex characters)\"\n\nYour encryption key is not the correct length. Generate a new one using the commands above.\n\n### \"BETTER_AUTH_SECRET must be at least 32 characters\"\n\nYour auth secret is too short. Generate a longer one using the commands above.\n\n### Onboarding screen does not appear / app loads empty or very slowly\n\nThis is almost always caused by a wrong `BASE_URL`. When `BASE_URL` points to an incorrect or unreachable address, the frontend cannot reach the backend for session and onboarding checks, which causes it to silently show empty state.\n\n**Make sure `BASE_URL` matches the EXACT URL YOU put into the browser address bar (including possible LAN IPs or the domain):**\n\n```bash\n# Example (Docker on LAN):\nBASE_URL=http://192.168.1.123:3000\n\n# Example (Production):\nBASE_URL=https://status.example.com\n```\n\nYou can verify the value your container is using by checking the config endpoint:\n\n```bash\ncurl http://localhost:3000/api/config\n# Expected: {\"baseUrl\":\"http://localhost:3000\"}\n```\n\nIf `baseUrl` in the response points to port `5173` or any other wrong address, update `BASE_URL` in your `.env` file and recreate the container:\n\n```bash\ndocker compose up -d --force-recreate\n```\n\n### Database connection errors\n\n- Verify your `DATABASE_URL` is correct and the database is reachable\n- Ensure PostgreSQL is running and accepting connections\n- Check firewall rules allow connections between containers\n\n## Next Steps\n\n- [Configure authentication strategies](/checkstack/user-guide/reference/authentication-strategies/)\n- [Set up notification channels](/checkstack/developer-guide/backend/notifications/strategies/)\n- [Create your first health check](/checkstack/developer-guide/backend/healthchecks/strategies/)",
|
|
2577
2579
|
"truncated": false
|
|
2578
2580
|
},
|
|
2579
2581
|
{
|
|
@@ -2729,7 +2731,7 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
2729
2731
|
"Example `.env`",
|
|
2730
2732
|
"Where to go next"
|
|
2731
2733
|
],
|
|
2732
|
-
"content": "Checkstack is configured almost entirely through environment variables. The platform reads them once at process start (or on the first request that touches them) and there is no runtime reload. To change a value, restart the container.\n\nThis page is the authoritative list. If you are just looking for the minimum required to boot, see [Run Checkstack with Docker](/checkstack/user-guide/installation/docker/) and come back here when you need detail.\n\n> [!NOTE]\n> All variables are read with `process.env`. They must be set on the **backend** container. Frontend assets are served by the backend, so there are no separate frontend env vars to set at deploy time.\n\n## Core\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `BASE_URL` | yes | none | The exact public URL operators type into the browser to reach Checkstack (e.g. `http://192.168.1.10:3000`, `https://status.example.com`). Used for CORS, OAuth redirects, SAML reply URLs, OpenAPI server URL, notification links, and the runtime config endpoint at `/api/config`. Must match the address you actually use; a mismatch silently breaks SSO redirects and shows a blank UI. |\n| `NODE_ENV` | no | `development` | Set to `production` for JSON-formatted logs and to disable the dev log files under `.dev/logs/`. Also blocks `CHECKSTACK_DEV_AUTH=true` (it throws on boot when set in production). |\n| `CHECKSTACK_FRONTEND_DIST` | no (set by Docker image) | unset | Absolute path to the built frontend `dist/` directory. The official Docker image sets it to `/app/core/frontend/dist`. If you run the backend without static assets, leave this unset and serve the frontend separately. |\n| `INTERNAL_URL` | no | falls back to `BASE_URL` or `http://localhost:3000` | URL used for backend-to-backend RPC inside the cluster. Set this to a cluster-internal address (e.g. a Kubernetes service name like `http://checkstack-svc:3000`) when running multiple replicas so internal traffic skips the external load balancer. |\n\n## Database\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `DATABASE_URL` | yes | none | PostgreSQL connection string used by every backend package (`postgresql://user:pass@host:5432/db`). All plugin schemas live in this one database; per-plugin tables are isolated via PostgreSQL schemas (`plugin_<id>`). No extensions are required. Postgres 16 is the version shipped in the reference compose file. |\n\n> [!IMPORTANT]\n> Checkstack does not support SQLite. The platform relies on Postgres-specific features (JSONB, schema namespacing, `LISTEN`/`NOTIFY` style queueing via plugins). Any reachable Postgres 14+ instance works; managed services like Neon or Supabase are fine.\n\n## Authentication\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `BETTER_AUTH_SECRET` | yes | none | Signs session cookies and OAuth state. Must be at least 32 characters. Used by `core/auth-backend` and the SAML plugin. Treat this like a JWT signing key: rotating it logs every user out. |\n| `PUBLIC_URL` | no (SAML only) | falls back to `BASE_URL` | Optional override the SAML plugin uses to build identity-provider callback URLs. Only set this if the public URL exposed to your IdP differs from `BASE_URL` (rare). |\n\nFor the strategy-by-strategy walkthrough (credential, GitHub OAuth, SAML, LDAP, group mapping) see [Authentication strategies](/checkstack/user-guide/reference/authentication-strategies/).\n\n## Encryption\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `ENCRYPTION_MASTER_KEY` | yes | none | 64-character hex string (32 bytes) used as the AES-256-GCM key for every secret stored in the database: OAuth client secrets, integration API tokens, satellite tokens, etc. Without it, the platform refuses to boot any feature that stores a secret. See [Secret encryption](/checkstack/user-guide/reference/secret-encryption/) for generation and rotation details. |\n\n## Logging\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `LOG_LEVEL` | no | `info` | Winston log level. One of `error`, `warn`, `info`, `debug`. Setting `debug` is verbose but useful when diagnosing plugin install or queue lag issues. |\n| `DEBUG` | no | unset | Only honoured by the satellite agent. Any non-empty value enables `[satellite:debug]` lines on stdout. |\n\n## Plugin development\n\nThese variables only apply when you are developing a plugin locally; never set them in production.\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `CHECKSTACK_DEV_PLUGIN_PATH` | no | unset | Absolute path to a plugin directory whose default export is a `BackendPlugin`. Setting this skips filesystem discovery and loads only that plugin plus core services. Used by `bunx @checkstack/
|
|
2734
|
+
"content": "Checkstack is configured almost entirely through environment variables. The platform reads them once at process start (or on the first request that touches them) and there is no runtime reload. To change a value, restart the container.\n\nThis page is the authoritative list. If you are just looking for the minimum required to boot, see [Run Checkstack with Docker](/checkstack/user-guide/installation/docker/) and come back here when you need detail.\n\n> [!NOTE]\n> All variables are read with `process.env`. They must be set on the **backend** container. Frontend assets are served by the backend, so there are no separate frontend env vars to set at deploy time.\n\n## Core\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `BASE_URL` | yes | none | The exact public URL operators type into the browser to reach Checkstack (e.g. `http://192.168.1.10:3000`, `https://status.example.com`). Used for CORS, OAuth redirects, SAML reply URLs, OpenAPI server URL, notification links, and the runtime config endpoint at `/api/config`. Must match the address you actually use; a mismatch silently breaks SSO redirects and shows a blank UI. |\n| `NODE_ENV` | no | `development` | Set to `production` for JSON-formatted logs and to disable the dev log files under `.dev/logs/`. Also blocks `CHECKSTACK_DEV_AUTH=true` (it throws on boot when set in production). |\n| `CHECKSTACK_FRONTEND_DIST` | no (set by Docker image) | unset | Absolute path to the built frontend `dist/` directory. The official Docker image sets it to `/app/core/frontend/dist`. If you run the backend without static assets, leave this unset and serve the frontend separately. |\n| `INTERNAL_URL` | no | falls back to `BASE_URL` or `http://localhost:3000` | URL used for backend-to-backend RPC inside the cluster. Set this to a cluster-internal address (e.g. a Kubernetes service name like `http://checkstack-svc:3000`) when running multiple replicas so internal traffic skips the external load balancer. |\n\n## Database\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `DATABASE_URL` | yes | none | PostgreSQL connection string used by every backend package (`postgresql://user:pass@host:5432/db`). All plugin schemas live in this one database; per-plugin tables are isolated via PostgreSQL schemas (`plugin_<id>`). No extensions are required. Postgres 16 is the version shipped in the reference compose file. |\n\n> [!IMPORTANT]\n> Checkstack does not support SQLite. The platform relies on Postgres-specific features (JSONB, schema namespacing, `LISTEN`/`NOTIFY` style queueing via plugins). Any reachable Postgres 14+ instance works; managed services like Neon or Supabase are fine.\n\n## Authentication\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `BETTER_AUTH_SECRET` | yes | none | Signs session cookies and OAuth state. Must be at least 32 characters. Used by `core/auth-backend` and the SAML plugin. Treat this like a JWT signing key: rotating it logs every user out. |\n| `PUBLIC_URL` | no (SAML only) | falls back to `BASE_URL` | Optional override the SAML plugin uses to build identity-provider callback URLs. Only set this if the public URL exposed to your IdP differs from `BASE_URL` (rare). |\n\nFor the strategy-by-strategy walkthrough (credential, GitHub OAuth, SAML, LDAP, group mapping) see [Authentication strategies](/checkstack/user-guide/reference/authentication-strategies/).\n\n## Encryption\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `ENCRYPTION_MASTER_KEY` | yes | none | 64-character hex string (32 bytes) used as the AES-256-GCM key for every secret stored in the database: OAuth client secrets, integration API tokens, satellite tokens, etc. Without it, the platform refuses to boot any feature that stores a secret. See [Secret encryption](/checkstack/user-guide/reference/secret-encryption/) for generation and rotation details. |\n\n## Logging\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `LOG_LEVEL` | no | `info` | Winston log level. One of `error`, `warn`, `info`, `debug`. Setting `debug` is verbose but useful when diagnosing plugin install or queue lag issues. |\n| `DEBUG` | no | unset | Only honoured by the satellite agent. Any non-empty value enables `[satellite:debug]` lines on stdout. |\n\n## Plugin development\n\nThese variables only apply when you are developing a plugin locally; never set them in production.\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `CHECKSTACK_DEV_PLUGIN_PATH` | no | unset | Absolute path to a plugin directory whose default export is a `BackendPlugin`. Setting this skips filesystem discovery and loads only that plugin plus core services. Used by the dev server (`bunx @checkstack/dev-server@latest`, or the `checkstack-dev` bin). |\n| `CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS` | no | unset | JSON array of additional plugin module paths to co-load alongside the one under `CHECKSTACK_DEV_PLUGIN_PATH`. The dev script sets this automatically based on `package.json` dependencies. |\n| `CHECKSTACK_DEV_AUTH` | no | `false` | When `true`, registers a synthetic auth service that auto-grants every access rule. Strictly refused when `NODE_ENV=production` (the process throws at boot). Useful for testing plugins without going through a login flow. |\n\n> [!WARNING]\n> `CHECKSTACK_DEV_AUTH=true` disables every access guard in the platform. Never set it on an exposed instance.\n\n## Satellite agent\n\nThe satellite is a separate process. These variables apply to the satellite container only, not to the core backend.\n\n| Variable | Required | Default | What it does |\n|----------|----------|---------|--------------|\n| `CHECKSTACK_CORE_URL` | yes | none | URL of the Checkstack core the satellite connects to. Reachable from wherever the satellite runs. |\n| `CHECKSTACK_SATELLITE_CLIENT_ID` | yes | none | Stable id you assign in the Satellite registration UI. Identifies this satellite to the core. |\n| `CHECKSTACK_SATELLITE_TOKEN` | yes | none | Bearer token issued by the core when the satellite was registered. Treat as a credential. |\n| `DEBUG` | no | unset | Enables verbose satellite logs. |\n\n## Docker Compose helpers\n\nThe reference `docker-compose.yml` adds a few Postgres-side variables for convenience. They are read by the Postgres image, not by Checkstack itself.\n\n| Variable | Default in compose | What it does |\n|----------|--------------------|--------------|\n| `POSTGRES_USER` | `checkstack` | DB user the Postgres container creates on first boot. Plugged into `DATABASE_URL`. |\n| `POSTGRES_PASSWORD` | `checkstack` | Password for the above user. Change this for any non-throwaway install. |\n| `POSTGRES_DB` | `checkstack` | Database name created on first boot. |\n\n## Example `.env`\n\nA complete `.env` for a single-host production install looks like this:\n\n```env\n# Core\nBASE_URL=https://status.example.com\nNODE_ENV=production\nLOG_LEVEL=info\n\n# Database\nPOSTGRES_USER=checkstack\nPOSTGRES_PASSWORD=replace-me\nPOSTGRES_DB=checkstack\n\n# Auth\nBETTER_AUTH_SECRET=replace-with-32-plus-char-random-string\n\n# Encryption\nENCRYPTION_MASTER_KEY=replace-with-64-hex-chars\n```\n\nFor multi-replica deployments, add:\n\n```env\nINTERNAL_URL=http://checkstack-svc:3000\n```\n\n## Where to go next\n\n- [Run Checkstack with Docker](/checkstack/user-guide/installation/docker/) for the install flow that feeds these variables.\n- [Secret encryption](/checkstack/user-guide/reference/secret-encryption/) for generating and rotating `ENCRYPTION_MASTER_KEY`.\n- [Authentication strategies](/checkstack/user-guide/reference/authentication-strategies/) for the strategy-side configuration you set from the UI on top of the env vars above.\n- [Installation troubleshooting](/checkstack/user-guide/troubleshooting/installation/) when boot fails or environment values look wrong.",
|
|
2733
2735
|
"truncated": false
|
|
2734
2736
|
},
|
|
2735
2737
|
{
|
|
@@ -3011,10 +3013,10 @@ export const DOCS_INDEX: readonly DocsIndexEntry[] = [
|
|
|
3011
3013
|
"\"Plugin loaded on one replica but not another\"",
|
|
3012
3014
|
"Where to go next"
|
|
3013
3015
|
],
|
|
3014
|
-
"content": "This page covers the failure modes you hit while installing, upgrading, or removing plugins through the Plugin Manager. For the install walkthrough see [Install a plugin](/checkstack/user-guide/guides/install-a-plugin/).\n\n## Install fails\n\n### Symptom: \"Tarball exceeds maximum size\"\n\nThe plugin tarball is larger than the platform's 50 MB ceiling.\n\n**How to verify** - the error message includes the actual size.\n\n**Fix** - this limit is hardcoded; there is no env var or UI knob. Ask the plugin author to slim the package (typically by adding a tighter `files` field to `package.json` so dev artifacts aren't shipped). If you control the plugin, repack with `bunx @checkstack/scripts plugin-pack` after pruning.\n\n### Symptom: \"Failed to peek tarball\"\n\nThe uploaded `.tgz` is corrupted or missing the required `package.json`/`checkstack` metadata.\n\n**Fix**:\n\n1. Re-download or re-pack the artifact.\n2. Confirm the package was packed with `bunx @checkstack/scripts plugin-pack` (not `bun pm pack` directly). The pack CLI validates the `checkstack` metadata block before producing the tarball.\n\n### Symptom: npm install fails with `404 Not Found`\n\nThe npm registry doesn't have the package at the requested version.\n\n**Fix**:\n\n1. Double-check the package name and version spelled in the install dialog.\n2. If the package is private, configure the npm registry credentials in **Settings -> Infrastructure**. The npm installer honours the registry config the platform-wide.\n3. Confirm the version actually exists with `npm view @org/pkg versions`.\n\n### Symptom: GitHub install can't reach the release\n\nThe GitHub installer fetches a release asset by URL. Failures here are almost always network or auth.\n\n**Fix**:\n\n1. Check the Checkstack container can reach `https://github.com` and `https://objects.githubusercontent.com` outbound.\n2. For private repos, the release asset must be accessible without auth, or you must configure a GitHub token in the integration settings.\n3. Verify the release URL points to a `.tgz` asset, not a source archive.\n\n### Symptom: install warns about install scripts\n\nThe plugin's `package.json` sets `checkstack.allowInstallScripts: true`. The platform refuses to silently run lifecycle scripts (`preinstall`, `postinstall`, ...) - it shows a warning and asks for explicit confirmation.\n\n**Fix** - read the plugin's docs/source to confirm what the script does. If you trust it, click Install to proceed. If you don't, don't install. This warning is loud on purpose; install scripts run with the same permissions as the Checkstack process.\n\n### Symptom: \"Plugin signature could not be verified\"\n\nReserved for a future signed-plugin flow. Current v1.0 BETA installs do not require signatures, so if you see this you're on a build that has signature enforcement turned on - either install from a trusted source that signs releases or temporarily relax the policy in Infrastructure settings.\n\n## Version compatibility\n\nCheckstack v1.0 is **BETA**. The platform follows semver and treats every release as potentially containing breaking changes until v1.0 stable.\n\n| Situation | What to do |\n|-----------|------------|\n| Plugin built against a newer core API | Update the core first; plugins generally tolerate newer cores within the same minor. |\n| Plugin built against a much older core | Update the plugin. Plugins built for pre-v1.0 cores have no compatibility guarantee. |\n| Bundle install with mixed versions | The pack CLI emits a single bundle manifest with one primary version; if a sibling is mismatched, repack the bundle. |\n\n> [!IMPORTANT]\n> Per the changesets rule, the platform is currently in BETA and only bumps minor versions even for breaking changes (with a BREAKING CHANGES note in the changeset). Treat every minor bump as a potential plugin-compatibility checkpoint and read the changelog before upgrading.\n\n## Upgrade behaviour\n\n### When a new plugin version is published, does Checkstack pick it up?\n\nNo. The platform never upgrades plugins on its own. You upgrade explicitly through the Plugin Manager.\n\n### Does restarting the container upgrade plugins?\n\nOn restart, the platform re-loads every `is_uninstallable=true` row in the `plugins` table from the `plugin_artifacts` store. It does **not** re-fetch from npm/GitHub; the tarball in the database is the source of truth. So a restart preserves the exact version you installed, even if upstream has moved on.\n\n### What about core plugins (the built-in ones)?\n\nCore plugins ship inside the Checkstack image. Pulling a newer image upgrades them as a set. There is no per-core-plugin version pinning.\n\n## Uninstall behaviour\n\nWhen you uninstall a runtime plugin from the Plugin Manager, three pieces of state can be touched. The UI exposes each as an explicit toggle on the uninstall preview screen:\n\n| State | What it is | What you choose |\n|-------|------------|-----------------|\n| Plugin row + tarball | The `plugins` and `plugin_artifacts` rows for the plugin. Always removed on uninstall. | No choice. |\n| Plugin Postgres schema | The per-plugin schema (`plugin_<id>`) holding the plugin's tables. Drops everything the plugin ever stored. | \"Delete schema\" toggle. **Defaults to off**. |\n| Plugin configs | Per-plugin configuration rows in `plugin_configs`. | \"Delete configs\" toggle. **Defaults to off**. |\n\nThe defaults are conservative on purpose: an uninstall is reversible (re-install the same plugin and the configs/data are still there) as long as you don't tick those toggles.\n\n> [!CAUTION]\n> Ticking \"Delete schema\" drops the plugin's data tables with `DROP SCHEMA ... CASCADE`. There is no undo. Take a Postgres backup first if you might want the data back.\n\n### Cascading uninstalls\n\nIf other installed plugins declare a `@checkstack/<id>` dependency on the plugin you're removing, the preview screen shows them as **Dependents**. You then choose:\n\n- Uninstall without cascade -> the platform refuses (would leave dangling deps).\n- Uninstall with cascade -> the platform walks the dependents transitively and uninstalls them too, in dependency order. Each dependent honours the same \"delete schema\" / \"delete configs\" toggles you picked.\n\n### Core plugins cannot be uninstalled\n\nThe Uninstall action is hidden for any plugin where `isUninstallable=false`. Those are the built-in core plugins; removing them would brick the platform. Use the official Docker image to swap the core itself.\n\n## \"Plugin loaded on one replica but not another\"\n\nIn a multi-replica deployment, every install/uninstall is broadcast to every replica. If a single replica didn't pick the install up, check the **Plugin Manager -> Plugin events** page - each lifecycle event is recorded per replica, so you can see exactly which one failed and why.\n\nMost-common causes:\n\n- The failing replica was restarting at the moment of the broadcast and missed it. Hitting the restart endpoint on the failing replica forces a re-bootstrap from `plugin_artifacts` (which contains the install tarball regardless of the original source).\n- The failing replica's `INTERNAL_URL` is wrong, so the broadcast couldn't be acked back to the originator.\n\n## Where to go next\n\n- [Install a plugin](/checkstack/user-guide/guides/install-a-plugin/) - the install walkthrough.\n- [Plugin distribution](/checkstack/developer-guide/architecture/plugin-distribution/) - the developer-facing distribution rules.\n- [Plugin system architecture](/checkstack/developer-guide/architecture/plugin-system/) - lifecycle and multi-instance coordination details.",
|
|
3016
|
+
"content": "This page covers the failure modes you hit while installing, upgrading, or removing plugins through the Plugin Manager. For the install walkthrough see [Install a plugin](/checkstack/user-guide/guides/install-a-plugin/).\n\n## Install fails\n\n### Symptom: \"Tarball exceeds maximum size\"\n\nThe plugin tarball is larger than the platform's 50 MB ceiling.\n\n**How to verify** - the error message includes the actual size.\n\n**Fix** - this limit is hardcoded; there is no env var or UI knob. Ask the plugin author to slim the package (typically by adding a tighter `files` field to `package.json` so dev artifacts aren't shipped). If you control the plugin, repack with `bunx @checkstack/scripts@latest plugin-pack` after pruning.\n\n### Symptom: \"Failed to peek tarball\"\n\nThe uploaded `.tgz` is corrupted or missing the required `package.json`/`checkstack` metadata.\n\n**Fix**:\n\n1. Re-download or re-pack the artifact.\n2. Confirm the package was packed with `bunx @checkstack/scripts@latest plugin-pack` (not `bun pm pack` directly). The pack CLI validates the `checkstack` metadata block before producing the tarball.\n\n### Symptom: npm install fails with `404 Not Found`\n\nThe npm registry doesn't have the package at the requested version.\n\n**Fix**:\n\n1. Double-check the package name and version spelled in the install dialog.\n2. If the package is private, configure the npm registry credentials in **Settings -> Infrastructure**. The npm installer honours the registry config the platform-wide.\n3. Confirm the version actually exists with `npm view @org/pkg versions`.\n\n### Symptom: GitHub install can't reach the release\n\nThe GitHub installer fetches a release asset by URL. Failures here are almost always network or auth.\n\n**Fix**:\n\n1. Check the Checkstack container can reach `https://github.com` and `https://objects.githubusercontent.com` outbound.\n2. For private repos, the release asset must be accessible without auth, or you must configure a GitHub token in the integration settings.\n3. Verify the release URL points to a `.tgz` asset, not a source archive.\n\n### Symptom: install warns about install scripts\n\nThe plugin's `package.json` sets `checkstack.allowInstallScripts: true`. The platform refuses to silently run lifecycle scripts (`preinstall`, `postinstall`, ...) - it shows a warning and asks for explicit confirmation.\n\n**Fix** - read the plugin's docs/source to confirm what the script does. If you trust it, click Install to proceed. If you don't, don't install. This warning is loud on purpose; install scripts run with the same permissions as the Checkstack process.\n\n### Symptom: \"Plugin signature could not be verified\"\n\nReserved for a future signed-plugin flow. Current v1.0 BETA installs do not require signatures, so if you see this you're on a build that has signature enforcement turned on - either install from a trusted source that signs releases or temporarily relax the policy in Infrastructure settings.\n\n## Version compatibility\n\nCheckstack v1.0 is **BETA**. The platform follows semver and treats every release as potentially containing breaking changes until v1.0 stable.\n\n| Situation | What to do |\n|-----------|------------|\n| Plugin built against a newer core API | Update the core first; plugins generally tolerate newer cores within the same minor. |\n| Plugin built against a much older core | Update the plugin. Plugins built for pre-v1.0 cores have no compatibility guarantee. |\n| Bundle install with mixed versions | The pack CLI emits a single bundle manifest with one primary version; if a sibling is mismatched, repack the bundle. |\n\n> [!IMPORTANT]\n> Per the changesets rule, the platform is currently in BETA and only bumps minor versions even for breaking changes (with a BREAKING CHANGES note in the changeset). Treat every minor bump as a potential plugin-compatibility checkpoint and read the changelog before upgrading.\n\n## Upgrade behaviour\n\n### When a new plugin version is published, does Checkstack pick it up?\n\nNo. The platform never upgrades plugins on its own. You upgrade explicitly through the Plugin Manager.\n\n### Does restarting the container upgrade plugins?\n\nOn restart, the platform re-loads every `is_uninstallable=true` row in the `plugins` table from the `plugin_artifacts` store. It does **not** re-fetch from npm/GitHub; the tarball in the database is the source of truth. So a restart preserves the exact version you installed, even if upstream has moved on.\n\n### What about core plugins (the built-in ones)?\n\nCore plugins ship inside the Checkstack image. Pulling a newer image upgrades them as a set. There is no per-core-plugin version pinning.\n\n## Uninstall behaviour\n\nWhen you uninstall a runtime plugin from the Plugin Manager, three pieces of state can be touched. The UI exposes each as an explicit toggle on the uninstall preview screen:\n\n| State | What it is | What you choose |\n|-------|------------|-----------------|\n| Plugin row + tarball | The `plugins` and `plugin_artifacts` rows for the plugin. Always removed on uninstall. | No choice. |\n| Plugin Postgres schema | The per-plugin schema (`plugin_<id>`) holding the plugin's tables. Drops everything the plugin ever stored. | \"Delete schema\" toggle. **Defaults to off**. |\n| Plugin configs | Per-plugin configuration rows in `plugin_configs`. | \"Delete configs\" toggle. **Defaults to off**. |\n\nThe defaults are conservative on purpose: an uninstall is reversible (re-install the same plugin and the configs/data are still there) as long as you don't tick those toggles.\n\n> [!CAUTION]\n> Ticking \"Delete schema\" drops the plugin's data tables with `DROP SCHEMA ... CASCADE`. There is no undo. Take a Postgres backup first if you might want the data back.\n\n### Cascading uninstalls\n\nIf other installed plugins declare a `@checkstack/<id>` dependency on the plugin you're removing, the preview screen shows them as **Dependents**. You then choose:\n\n- Uninstall without cascade -> the platform refuses (would leave dangling deps).\n- Uninstall with cascade -> the platform walks the dependents transitively and uninstalls them too, in dependency order. Each dependent honours the same \"delete schema\" / \"delete configs\" toggles you picked.\n\n### Core plugins cannot be uninstalled\n\nThe Uninstall action is hidden for any plugin where `isUninstallable=false`. Those are the built-in core plugins; removing them would brick the platform. Use the official Docker image to swap the core itself.\n\n## \"Plugin loaded on one replica but not another\"\n\nIn a multi-replica deployment, every install/uninstall is broadcast to every replica. If a single replica didn't pick the install up, check the **Plugin Manager -> Plugin events** page - each lifecycle event is recorded per replica, so you can see exactly which one failed and why.\n\nMost-common causes:\n\n- The failing replica was restarting at the moment of the broadcast and missed it. Hitting the restart endpoint on the failing replica forces a re-bootstrap from `plugin_artifacts` (which contains the install tarball regardless of the original source).\n- The failing replica's `INTERNAL_URL` is wrong, so the broadcast couldn't be acked back to the originator.\n\n## Where to go next\n\n- [Install a plugin](/checkstack/user-guide/guides/install-a-plugin/) - the install walkthrough.\n- [Plugin distribution](/checkstack/developer-guide/architecture/plugin-distribution/) - the developer-facing distribution rules.\n- [Plugin system architecture](/checkstack/developer-guide/architecture/plugin-system/) - lifecycle and multi-instance coordination details.",
|
|
3015
3017
|
"truncated": false
|
|
3016
3018
|
}
|
|
3017
3019
|
];
|
|
3018
3020
|
|
|
3019
3021
|
/** A content hash of the source tree, so a CI check can detect drift. */
|
|
3020
|
-
export const DOCS_INDEX_HASH = "
|
|
3022
|
+
export const DOCS_INDEX_HASH = "24a01723a26c2389adda98d7fde1e96d0b0e5e44ba42c04eb6c149bc060d6ddc";
|
package/src/serializer.test.ts
CHANGED
|
@@ -41,6 +41,28 @@ describe("serializeTool", () => {
|
|
|
41
41
|
);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("serializes a tool whose schema has Date fields (no throw)", () => {
|
|
45
|
+
// Regression for "The assistant hit an error: Date cannot be represented in
|
|
46
|
+
// JSON Schema": tools that read timestamped resources (incidents, health
|
|
47
|
+
// checks, anomalies) carry `z.date()` fields. Projecting the tool list must
|
|
48
|
+
// not throw - it runs on every chat turn before the model is even called.
|
|
49
|
+
const dateTool: RegisteredAiTool = {
|
|
50
|
+
name: "incident.get",
|
|
51
|
+
description: "Get an incident.",
|
|
52
|
+
effect: "read",
|
|
53
|
+
input: z.object({ id: z.string() }),
|
|
54
|
+
output: z.object({ id: z.string(), createdAt: z.date() }),
|
|
55
|
+
requiredAccessRules: ["incident.incident.read"],
|
|
56
|
+
execute: () => Promise.resolve({}),
|
|
57
|
+
};
|
|
58
|
+
const descriptor = serializeTool({ tool: dateTool });
|
|
59
|
+
const out = descriptor.outputSchema as {
|
|
60
|
+
properties: Record<string, Record<string, unknown>>;
|
|
61
|
+
};
|
|
62
|
+
expect(out.properties.createdAt?.type).toBe("string");
|
|
63
|
+
expect(out.properties.createdAt?.format).toBe("date-time");
|
|
64
|
+
});
|
|
65
|
+
|
|
44
66
|
test("never emits a secret VALUE into the descriptor", () => {
|
|
45
67
|
// A tool whose input has an x-secret field: the schema describes the field
|
|
46
68
|
// but the descriptor must never contain a concrete secret value.
|