@checkstack/backend-api 0.0.2

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.
@@ -0,0 +1,95 @@
1
+ import crypto from "node:crypto";
2
+
3
+ /**
4
+ * Master encryption key from environment variable.
5
+ * Should be 32 bytes (64 hex characters) for AES-256.
6
+ */
7
+ const getMasterKey = (): Buffer => {
8
+ const key = process.env.ENCRYPTION_MASTER_KEY;
9
+ if (!key) {
10
+ throw new Error(
11
+ "ENCRYPTION_MASTER_KEY environment variable is required for secret encryption"
12
+ );
13
+ }
14
+
15
+ // Convert hex string to buffer
16
+ const keyBuffer = Buffer.from(key, "hex");
17
+ if (keyBuffer.length !== 32) {
18
+ throw new Error(
19
+ "ENCRYPTION_MASTER_KEY must be 32 bytes (64 hex characters)"
20
+ );
21
+ }
22
+
23
+ return keyBuffer;
24
+ };
25
+
26
+ /**
27
+ * Encrypts a plaintext string using AES-256-GCM.
28
+ * Returns base64-encoded string in format: iv:authTag:ciphertext
29
+ */
30
+ export const encrypt = (plaintext: string): string => {
31
+ const key = getMasterKey();
32
+
33
+ // Generate random IV (12 bytes for GCM)
34
+ const iv = crypto.randomBytes(12);
35
+
36
+ // Create cipher
37
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
38
+
39
+ // Encrypt
40
+ const encrypted = Buffer.concat([
41
+ cipher.update(plaintext, "utf8"),
42
+ cipher.final(),
43
+ ]);
44
+
45
+ // Get auth tag
46
+ const authTag = cipher.getAuthTag();
47
+
48
+ // Return base64-encoded iv:authTag:ciphertext
49
+ return `${iv.toString("base64")}:${authTag.toString(
50
+ "base64"
51
+ )}:${encrypted.toString("base64")}`;
52
+ };
53
+
54
+ /**
55
+ * Decrypts an encrypted string.
56
+ * Expects base64-encoded format: iv:authTag:ciphertext
57
+ */
58
+ export const decrypt = (encrypted: string): string => {
59
+ const key = getMasterKey();
60
+
61
+ // Parse the encrypted string
62
+ const parts = encrypted.split(":");
63
+ if (parts.length !== 3) {
64
+ throw new Error("Invalid encrypted format");
65
+ }
66
+
67
+ const iv = Buffer.from(parts[0], "base64");
68
+ const authTag = Buffer.from(parts[1], "base64");
69
+ const ciphertext = Buffer.from(parts[2], "base64");
70
+
71
+ // Create decipher
72
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
73
+ decipher.setAuthTag(authTag);
74
+
75
+ // Decrypt
76
+ const decrypted = Buffer.concat([
77
+ decipher.update(ciphertext),
78
+ decipher.final(),
79
+ ]);
80
+
81
+ return decrypted.toString("utf8");
82
+ };
83
+
84
+ /**
85
+ * Checks if a value appears to be encrypted.
86
+ * Encrypted values follow the format: base64:base64:base64
87
+ */
88
+ export const isEncrypted = (value: string): boolean => {
89
+ const parts = value.split(":");
90
+ if (parts.length !== 3) return false;
91
+
92
+ // Check if all parts are valid base64
93
+ const base64Regex = /^[A-Za-z0-9+/]+=*$/;
94
+ return parts.every((part) => base64Regex.test(part));
95
+ };
@@ -0,0 +1,28 @@
1
+ import type { Hook, HookSubscribeOptions, HookUnsubscribe } from "./hooks";
2
+
3
+ /**
4
+ * EventBus interface for dependency injection
5
+ */
6
+ export interface EventBus {
7
+ subscribe<T>(
8
+ pluginId: string,
9
+ hook: Hook<T>,
10
+ listener: (payload: T) => Promise<void>,
11
+ options?: HookSubscribeOptions
12
+ ): Promise<HookUnsubscribe>;
13
+
14
+ /**
15
+ * Emit a hook through the distributed queue system.
16
+ * All instances receive broadcast hooks; one instance handles work-queue hooks.
17
+ */
18
+ emit<T>(hook: Hook<T>, payload: T): Promise<void>;
19
+
20
+ /**
21
+ * Emit a hook locally only (not distributed).
22
+ * Use for instance-local hooks that should only run on THIS instance.
23
+ * Uses Promise.allSettled to ensure one listener error doesn't block others.
24
+ */
25
+ emitLocal<T>(hook: Hook<T>, payload: T): Promise<void>;
26
+
27
+ shutdown(): Promise<void>;
28
+ }
@@ -0,0 +1,11 @@
1
+ export interface ExtensionPoint<T> {
2
+ id: string;
3
+ T: T; // phantom type for type safety
4
+ }
5
+
6
+ /**
7
+ * Creates a reference to an extension point.
8
+ */
9
+ export function createExtensionPoint<T>(id: string): ExtensionPoint<T> {
10
+ return { id } as ExtensionPoint<T>;
11
+ }
@@ -0,0 +1,68 @@
1
+ import { Versioned } from "./config-versioning";
2
+
3
+ /**
4
+ * Health check result with typed metadata.
5
+ * TMetadata is defined by each strategy's resultMetadata schema.
6
+ */
7
+ export interface HealthCheckResult<TMetadata = Record<string, unknown>> {
8
+ status: "healthy" | "unhealthy" | "degraded";
9
+ latencyMs?: number;
10
+ message?: string;
11
+ metadata?: TMetadata;
12
+ }
13
+
14
+ /**
15
+ * Raw run data for aggregation (passed to aggregateMetadata function).
16
+ */
17
+ export interface HealthCheckRunForAggregation<
18
+ TResultMetadata = Record<string, unknown>
19
+ > {
20
+ status: "healthy" | "unhealthy" | "degraded";
21
+ latencyMs?: number;
22
+ metadata?: TResultMetadata;
23
+ }
24
+
25
+ /**
26
+ * Health check strategy definition with typed config and result.
27
+ * @template TConfig - Configuration type for this strategy
28
+ * @template TResult - Per-run result type
29
+ * @template TAggregatedResult - Aggregated result type for buckets
30
+ */
31
+ export interface HealthCheckStrategy<
32
+ TConfig = unknown,
33
+ TResult = Record<string, unknown>,
34
+ TAggregatedResult = Record<string, unknown>
35
+ > {
36
+ id: string;
37
+ displayName: string;
38
+ description?: string;
39
+
40
+ /** Configuration schema with versioning and migrations */
41
+ config: Versioned<TConfig>;
42
+
43
+ /** Optional result schema with versioning and migrations */
44
+ result?: Versioned<TResult>;
45
+
46
+ /** Aggregated result schema for long-term bucket storage */
47
+ aggregatedResult: Versioned<TAggregatedResult>;
48
+
49
+ execute(config: TConfig): Promise<HealthCheckResult<TResult>>;
50
+
51
+ /**
52
+ * Aggregate results from multiple runs into a summary for bucket storage.
53
+ * Called during retention processing when raw data is aggregated.
54
+ * Core metrics (counts, latency) are auto-calculated by platform.
55
+ * This function only handles strategy-specific result aggregation.
56
+ */
57
+ aggregateResult(
58
+ runs: HealthCheckRunForAggregation<TResult>[]
59
+ ): TAggregatedResult;
60
+ }
61
+
62
+ export interface HealthCheckRegistry {
63
+ register(strategy: HealthCheckStrategy<unknown, unknown, unknown>): void;
64
+ getStrategy(
65
+ id: string
66
+ ): HealthCheckStrategy<unknown, unknown, unknown> | undefined;
67
+ getStrategies(): HealthCheckStrategy<unknown, unknown, unknown>[];
68
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,182 @@
1
+ import type { Permission } from "@checkstack/common";
2
+
3
+ /**
4
+ * Hook definition for type-safe event emission and subscription
5
+ */
6
+ export interface Hook<T = unknown> {
7
+ id: string;
8
+ _type?: T; // Phantom type for TypeScript inference
9
+ }
10
+
11
+ /**
12
+ * Create a typed hook
13
+ */
14
+ export function createHook<T>(id: string): Hook<T> {
15
+ return { id } as Hook<T>;
16
+ }
17
+
18
+ /**
19
+ * Core platform hooks
20
+ */
21
+ export const coreHooks = {
22
+ /**
23
+ * Emitted when a plugin registers permissions
24
+ */
25
+ permissionsRegistered: createHook<{
26
+ pluginId: string;
27
+ permissions: Permission[];
28
+ }>("core.permissions.registered"),
29
+
30
+ /**
31
+ * Emitted when plugin configuration is updated
32
+ */
33
+ configUpdated: createHook<{
34
+ pluginId: string;
35
+ key: string;
36
+ value: unknown;
37
+ }>("core.config.updated"),
38
+
39
+ /**
40
+ * Emitted when a plugin completes initialization (Phase 2)
41
+ */
42
+ pluginInitialized: createHook<{
43
+ pluginId: string;
44
+ }>("core.plugin.initialized"),
45
+
46
+ /**
47
+ * Distributed request to deregister a plugin.
48
+ * All instances receive this and should perform local cleanup.
49
+ * Emitted via broadcast mode.
50
+ */
51
+ pluginDeregistrationRequested: createHook<{
52
+ pluginId: string;
53
+ deleteSchema: boolean;
54
+ }>("core.plugin.deregistration-requested"),
55
+
56
+ /**
57
+ * INSTANCE-LOCAL: Emitted BEFORE a plugin begins shutdown on THIS instance.
58
+ * Use emitLocal() to emit this hook - it does NOT go through the Queue.
59
+ * Listeners should perform cleanup that depends on cross-plugin services.
60
+ */
61
+ pluginDeregistering: createHook<{
62
+ pluginId: string;
63
+ reason: "uninstall" | "disable" | "shutdown";
64
+ }>("core.plugin.deregistering"),
65
+
66
+ /**
67
+ * Emitted AFTER a plugin has been fully removed.
68
+ * Use this for orphan cleanup (e.g., removing permissions from DB).
69
+ * Should be emitted with work-queue mode for DB operations.
70
+ */
71
+ pluginDeregistered: createHook<{
72
+ pluginId: string;
73
+ }>("core.plugin.deregistered"),
74
+
75
+ /**
76
+ * Emitted when the platform is shutting down.
77
+ * Gives plugins time to gracefully cleanup resources.
78
+ */
79
+ platformShutdown: createHook<{
80
+ reason: "signal" | "error" | "manual";
81
+ }>("core.platform.shutdown"),
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────
84
+ // Plugin Installation (Multi-Instance Coordination)
85
+ // ─────────────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Distributed request to install/enable a plugin.
89
+ * All instances receive this and should load the plugin into memory.
90
+ * Emitted via broadcast mode.
91
+ */
92
+ pluginInstallationRequested: createHook<{
93
+ pluginId: string;
94
+ pluginPath: string;
95
+ }>("core.plugin.installation-requested"),
96
+
97
+ /**
98
+ * INSTANCE-LOCAL: Emitted when a plugin is being loaded on THIS instance.
99
+ * Use emitLocal() to emit this hook - it does NOT go through the Queue.
100
+ */
101
+ pluginInstalling: createHook<{
102
+ pluginId: string;
103
+ }>("core.plugin.installing"),
104
+
105
+ /**
106
+ * Emitted AFTER a plugin has been fully installed and loaded.
107
+ * Use this for post-installation tasks (e.g., syncing to DB).
108
+ * Should be emitted with work-queue mode for DB operations.
109
+ */
110
+ pluginInstalled: createHook<{
111
+ pluginId: string;
112
+ }>("core.plugin.installed"),
113
+ } as const;
114
+
115
+ /**
116
+ * Hook subscription options using discriminated union for type safety.
117
+ *
118
+ * When mode is 'work-queue', workerGroup is REQUIRED at compile-time.
119
+ * When mode is 'broadcast', workerGroup is not allowed.
120
+ */
121
+ export type HookSubscribeOptions =
122
+ | {
123
+ /**
124
+ * Broadcast mode: All instances receive and process the hook
125
+ */
126
+ mode?: "broadcast";
127
+ /**
128
+ * Not allowed in broadcast mode
129
+ */
130
+ workerGroup?: never;
131
+ /**
132
+ * Maximum retry attempts (not used in broadcast mode)
133
+ */
134
+ maxRetries?: number;
135
+ }
136
+ | {
137
+ /**
138
+ * Work-queue mode: Only one instance processes (load-balanced with retry)
139
+ */
140
+ mode: "work-queue";
141
+ /**
142
+ * Worker group identifier. REQUIRED for work-queue mode.
143
+ *
144
+ * Workers with the same group name compete (only one processes the message).
145
+ * Workers with different group names both process the message.
146
+ *
147
+ * Automatically namespaced by plugin ID to prevent conflicts.
148
+ *
149
+ * Examples:
150
+ * - 'db-sync' → becomes 'my-plugin.db-sync'
151
+ * - 'email-sender' → becomes 'my-plugin.email-sender'
152
+ */
153
+ workerGroup: string;
154
+ /**
155
+ * Maximum retry attempts for work-queue mode
156
+ * @default 3
157
+ */
158
+ maxRetries?: number;
159
+ }
160
+ | {
161
+ /**
162
+ * Instance-local mode: Events bypass the Queue and run in-memory only.
163
+ * Use for cleanup hooks that should NOT be distributed across instances.
164
+ * Emitted via emitLocal() instead of emit().
165
+ */
166
+ mode: "instance-local";
167
+ /**
168
+ * Not allowed in instance-local mode
169
+ */
170
+ workerGroup?: never;
171
+ /**
172
+ * Not applicable in instance-local mode
173
+ */
174
+ maxRetries?: never;
175
+ };
176
+
177
+ /**
178
+ * Handle for unsubscribing from a hook
179
+ */
180
+ export interface HookUnsubscribe {
181
+ (): Promise<void>;
182
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export * from "./service-ref";
2
+ export * from "./extension-point";
3
+ export * from "./core-services";
4
+ export * from "./plugin-system";
5
+ export * from "./health-check";
6
+ export * from "./auth-strategy";
7
+ export * from "./zod-config";
8
+ export * from "./encryption";
9
+ export * from "./schema-utils";
10
+ export * from "./config-service";
11
+ export * from "zod";
12
+ export * from "./config-versioning";
13
+ export * from "./rpc";
14
+ export * from "./test-utils";
15
+ export * from "./hooks";
16
+ export * from "./event-bus-types";
17
+ export * from "./plugin-admin-contract";
18
+ export * from "./notification-strategy";
19
+ export * from "./oauth-handler";
20
+ export * from "./markdown";
21
+ export * from "./email-layout";
22
+ export * from "./assertions";
23
+ export * from "./chart-metadata";
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ markdownToHtml,
4
+ markdownToPlainText,
5
+ markdownToSlackMrkdwn,
6
+ } from "./markdown";
7
+
8
+ describe("markdownToHtml", () => {
9
+ it("converts bold text", () => {
10
+ const result = markdownToHtml("**bold**");
11
+ expect(result).toContain("<strong>bold</strong>");
12
+ });
13
+
14
+ it("converts italic text", () => {
15
+ const result = markdownToHtml("*italic*");
16
+ expect(result).toContain("<em>italic</em>");
17
+ });
18
+
19
+ it("converts links", () => {
20
+ const result = markdownToHtml("[link](https://example.com)");
21
+ expect(result).toContain('<a href="https://example.com">link</a>');
22
+ });
23
+
24
+ it("converts headers", () => {
25
+ const result = markdownToHtml("# Header");
26
+ expect(result).toContain("<h1");
27
+ expect(result).toContain("Header");
28
+ });
29
+
30
+ it("converts unordered lists", () => {
31
+ const result = markdownToHtml("- item 1\n- item 2");
32
+ expect(result).toContain("<ul>");
33
+ expect(result).toContain("<li>item 1</li>");
34
+ expect(result).toContain("<li>item 2</li>");
35
+ });
36
+
37
+ it("converts code blocks", () => {
38
+ const result = markdownToHtml("```\ncode\n```");
39
+ expect(result).toContain("<code>");
40
+ });
41
+
42
+ it("converts inline code", () => {
43
+ const result = markdownToHtml("use `code` here");
44
+ expect(result).toContain("<code>code</code>");
45
+ });
46
+ });
47
+
48
+ describe("markdownToPlainText", () => {
49
+ it("strips bold formatting", () => {
50
+ const result = markdownToPlainText("**bold** text");
51
+ expect(result).toBe("bold text");
52
+ });
53
+
54
+ it("strips italic formatting", () => {
55
+ const result = markdownToPlainText("*italic* text");
56
+ expect(result).toBe("italic text");
57
+ });
58
+
59
+ it("strips links but keeps text", () => {
60
+ const result = markdownToPlainText("[link](https://example.com)");
61
+ expect(result).toBe("link");
62
+ });
63
+
64
+ it("strips headers", () => {
65
+ const result = markdownToPlainText("# Header");
66
+ expect(result).toBe("Header");
67
+ });
68
+
69
+ it("preserves line breaks between paragraphs", () => {
70
+ const result = markdownToPlainText("First paragraph.\n\nSecond paragraph.");
71
+ expect(result).toContain("First paragraph.");
72
+ expect(result).toContain("Second paragraph.");
73
+ });
74
+
75
+ it("decodes HTML entities", () => {
76
+ const result = markdownToPlainText("A &amp; B");
77
+ expect(result).toBe("A & B");
78
+ });
79
+ });
80
+
81
+ describe("markdownToSlackMrkdwn", () => {
82
+ it("converts bold to Slack format", () => {
83
+ const result = markdownToSlackMrkdwn("**bold**");
84
+ expect(result).toBe("*bold*");
85
+ });
86
+
87
+ it("converts double underscore to Slack bold", () => {
88
+ const result = markdownToSlackMrkdwn("__bold__");
89
+ expect(result).toBe("*bold*");
90
+ });
91
+
92
+ it("converts links to Slack format", () => {
93
+ const result = markdownToSlackMrkdwn("[link](https://example.com)");
94
+ expect(result).toBe("<https://example.com|link>");
95
+ });
96
+
97
+ it("converts strikethrough", () => {
98
+ const result = markdownToSlackMrkdwn("~~strikethrough~~");
99
+ expect(result).toBe("~strikethrough~");
100
+ });
101
+
102
+ it("preserves inline code", () => {
103
+ const result = markdownToSlackMrkdwn("`code`");
104
+ expect(result).toBe("`code`");
105
+ });
106
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Markdown conversion utilities for notification strategies.
3
+ *
4
+ * These utilities allow strategies to convert markdown content to their
5
+ * native format (HTML for email, plain text for SMS, etc.).
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { marked } from "marked";
11
+
12
+ // Configure marked for email-safe HTML
13
+ marked.setOptions({
14
+ gfm: true, // GitHub Flavored Markdown
15
+ breaks: true, // Convert \n to <br>
16
+ });
17
+
18
+ /**
19
+ * Convert markdown to HTML (safe for email rendering).
20
+ *
21
+ * Uses GitHub Flavored Markdown with line break support.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const html = markdownToHtml("**Bold** and *italic*");
26
+ * // Returns: "<p><strong>Bold</strong> and <em>italic</em></p>"
27
+ * ```
28
+ */
29
+ export function markdownToHtml(markdown: string): string {
30
+ // marked.parse can return string | Promise<string>, but with sync config it's always string
31
+ return marked.parse(markdown) as string;
32
+ }
33
+
34
+ /**
35
+ * Strip markdown to plain text.
36
+ *
37
+ * Removes all formatting while preserving the content.
38
+ * Useful for strategies that don't support rich formatting (SMS, push).
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const text = markdownToPlainText("**Bold** and [link](https://example.com)");
43
+ * // Returns: "Bold and link"
44
+ * ```
45
+ */
46
+ export function markdownToPlainText(markdown: string): string {
47
+ // Convert to HTML first, then strip tags
48
+ const html = markdownToHtml(markdown);
49
+
50
+ // Strip HTML tags
51
+ let text = html.replaceAll(/<[^>]*>/g, "");
52
+
53
+ // Decode common HTML entities
54
+ text = text
55
+ .replaceAll("&amp;", "&")
56
+ .replaceAll("&lt;", "<")
57
+ .replaceAll("&gt;", ">")
58
+ .replaceAll("&quot;", '"')
59
+ .replaceAll("&#39;", "'")
60
+ .replaceAll("&nbsp;", " ");
61
+
62
+ // Collapse multiple whitespace/newlines
63
+ text = text.replaceAll(/\n\s*\n/g, "\n").trim();
64
+
65
+ return text;
66
+ }
67
+
68
+ /**
69
+ * Convert markdown to Slack mrkdwn format.
70
+ *
71
+ * Slack uses a different markdown flavor with ~strikethrough~ syntax
72
+ * and different link formatting.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * const mrkdwn = markdownToSlackMrkdwn("**Bold** and [link](https://example.com)");
77
+ * // Returns: "*Bold* and <https://example.com|link>"
78
+ * ```
79
+ */
80
+ export function markdownToSlackMrkdwn(markdown: string): string {
81
+ let result = markdown;
82
+
83
+ // Convert strikethrough first: ~~text~~ -> ~text~
84
+ result = result.replaceAll(/~~(.+?)~~/g, "~$1~");
85
+
86
+ // Convert links: [text](url) -> <url|text>
87
+ result = result.replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
88
+
89
+ // Convert italic (single underscore only): _text_ -> _text_
90
+ // Note: We skip *italic* conversion because Slack uses * for bold
91
+ // and it would conflict with the **bold** -> *bold* conversion
92
+
93
+ // Convert bold: **text** or __text__ -> *text*
94
+ result = result.replaceAll(/\*\*(.+?)\*\*/g, "*$1*");
95
+ result = result.replaceAll(/__(.+?)__/g, "*$1*");
96
+
97
+ // Convert inline code: `code` -> `code` (same in Slack)
98
+ // No change needed
99
+
100
+ // Convert code blocks: ```code``` -> ```code``` (same in Slack)
101
+ // No change needed
102
+
103
+ return result;
104
+ }