@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.
- package/CHANGELOG.md +228 -0
- package/package.json +33 -0
- package/src/assertions.test.ts +345 -0
- package/src/assertions.ts +371 -0
- package/src/auth-strategy.ts +58 -0
- package/src/chart-metadata.ts +77 -0
- package/src/config-service.ts +71 -0
- package/src/config-versioning.ts +310 -0
- package/src/contract.ts +8 -0
- package/src/core-services.ts +45 -0
- package/src/email-layout.ts +246 -0
- package/src/encryption.ts +95 -0
- package/src/event-bus-types.ts +28 -0
- package/src/extension-point.ts +11 -0
- package/src/health-check.ts +68 -0
- package/src/hooks.ts +182 -0
- package/src/index.ts +23 -0
- package/src/markdown.test.ts +106 -0
- package/src/markdown.ts +104 -0
- package/src/notification-strategy.ts +436 -0
- package/src/oauth-handler.ts +442 -0
- package/src/plugin-admin-contract.ts +64 -0
- package/src/plugin-system.ts +103 -0
- package/src/rpc.ts +284 -0
- package/src/schema-utils.ts +79 -0
- package/src/service-ref.ts +15 -0
- package/src/test-utils.ts +65 -0
- package/src/types.ts +111 -0
- package/src/zod-config.ts +149 -0
- package/tsconfig.json +6 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { os as baseOs, ORPCError, Router } from "@orpc/server";
|
|
2
|
+
import { AnyContractRouter } from "@orpc/contract";
|
|
3
|
+
import { HealthCheckRegistry } from "./health-check";
|
|
4
|
+
import {
|
|
5
|
+
QueuePluginRegistry,
|
|
6
|
+
QueueManager,
|
|
7
|
+
} from "@checkstack/queue-api";
|
|
8
|
+
import {
|
|
9
|
+
ProcedureMetadata,
|
|
10
|
+
qualifyPermissionId,
|
|
11
|
+
} from "@checkstack/common";
|
|
12
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
13
|
+
import {
|
|
14
|
+
Logger,
|
|
15
|
+
Fetch,
|
|
16
|
+
AuthService,
|
|
17
|
+
AuthUser,
|
|
18
|
+
RealUser,
|
|
19
|
+
ServiceUser,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
22
|
+
import type { Hook } from "./hooks";
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// CONTEXT TYPES
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Function type for emitting hooks from request handlers.
|
|
30
|
+
*/
|
|
31
|
+
export type EmitHookFn = <T>(hook: Hook<T>, payload: T) => Promise<void>;
|
|
32
|
+
|
|
33
|
+
export interface RpcContext {
|
|
34
|
+
/**
|
|
35
|
+
* The plugin metadata for this request.
|
|
36
|
+
* Use this to access pluginId and other plugin configuration.
|
|
37
|
+
*/
|
|
38
|
+
pluginMetadata: PluginMetadata;
|
|
39
|
+
|
|
40
|
+
db: NodePgDatabase<Record<string, unknown>>;
|
|
41
|
+
logger: Logger;
|
|
42
|
+
fetch: Fetch;
|
|
43
|
+
auth: AuthService;
|
|
44
|
+
user?: AuthUser;
|
|
45
|
+
healthCheckRegistry: HealthCheckRegistry;
|
|
46
|
+
queuePluginRegistry: QueuePluginRegistry;
|
|
47
|
+
queueManager: QueueManager;
|
|
48
|
+
/** Emit a hook event for cross-plugin communication */
|
|
49
|
+
emitHook: EmitHookFn;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Context with authenticated real user */
|
|
53
|
+
export interface UserRpcContext extends RpcContext {
|
|
54
|
+
user: RealUser;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Context with authenticated service */
|
|
58
|
+
export interface ServiceRpcContext extends RpcContext {
|
|
59
|
+
user: ServiceUser;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The core oRPC server instance for the entire backend.
|
|
64
|
+
* We use $context to define the required initial context for all procedures.
|
|
65
|
+
*/
|
|
66
|
+
export const os = baseOs.$context<RpcContext>();
|
|
67
|
+
|
|
68
|
+
// Re-export ProcedureMetadata from common for convenience
|
|
69
|
+
export type { ProcedureMetadata } from "@checkstack/common";
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// UNIFIED AUTH MIDDLEWARE
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Unified authentication and authorization middleware.
|
|
77
|
+
*
|
|
78
|
+
* Automatically enforces based on contract metadata:
|
|
79
|
+
* 1. User type (from meta.userType):
|
|
80
|
+
* - "anonymous": No authentication required, no permission checks
|
|
81
|
+
* - "public": Anyone can attempt, but permissions are checked (anonymous role for guests)
|
|
82
|
+
* - "user": Only real users (frontend authenticated)
|
|
83
|
+
* - "service": Only services (backend-to-backend)
|
|
84
|
+
* - "authenticated": Either users or services, but must be authenticated (default)
|
|
85
|
+
* 2. Permissions (from meta.permissions, only for real users or public anonymous)
|
|
86
|
+
*
|
|
87
|
+
* Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
|
|
88
|
+
*/
|
|
89
|
+
export const autoAuthMiddleware = os.middleware(
|
|
90
|
+
async ({ next, context, procedure }) => {
|
|
91
|
+
const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
|
|
92
|
+
const requiredUserType = meta?.userType || "authenticated";
|
|
93
|
+
const contractPermissions = meta?.permissions || [];
|
|
94
|
+
|
|
95
|
+
// Prefix contract permissions with pluginId to get fully-qualified permission IDs
|
|
96
|
+
// Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
|
|
97
|
+
const requiredPermissions = contractPermissions.map((p: string) =>
|
|
98
|
+
qualifyPermissionId(context.pluginMetadata, { id: p })
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Helper to wrap next() with error logging
|
|
102
|
+
const nextWithErrorLogging = async () => {
|
|
103
|
+
try {
|
|
104
|
+
return await next({});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Log the full error before oRPC sanitizes it to a generic 500
|
|
107
|
+
if (error instanceof ORPCError) {
|
|
108
|
+
// ORPCError is intentional - log at debug level
|
|
109
|
+
context.logger.debug("RPC error response:", {
|
|
110
|
+
code: error.code,
|
|
111
|
+
message: error.message,
|
|
112
|
+
data: error.data,
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
// Unexpected error - log at error level with full stack trace
|
|
116
|
+
context.logger.error("Unexpected RPC error:", error);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// 1. Handle anonymous endpoints - no auth required, no permission checks
|
|
123
|
+
if (requiredUserType === "anonymous") {
|
|
124
|
+
return nextWithErrorLogging();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. Handle public endpoints - anyone can attempt, but permissions are checked
|
|
128
|
+
if (requiredUserType === "public") {
|
|
129
|
+
if (context.user) {
|
|
130
|
+
// Authenticated user or application - check their permissions
|
|
131
|
+
if (
|
|
132
|
+
(context.user.type === "user" ||
|
|
133
|
+
context.user.type === "application") &&
|
|
134
|
+
requiredPermissions.length > 0
|
|
135
|
+
) {
|
|
136
|
+
const userPermissions = context.user.permissions || [];
|
|
137
|
+
const hasPermission = requiredPermissions.some(
|
|
138
|
+
(p: string) =>
|
|
139
|
+
userPermissions.includes("*") || userPermissions.includes(p)
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!hasPermission) {
|
|
143
|
+
throw new ORPCError("FORBIDDEN", {
|
|
144
|
+
message: `Missing permission: ${requiredPermissions.join(
|
|
145
|
+
" or "
|
|
146
|
+
)}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Services are trusted with all permissions
|
|
151
|
+
} else {
|
|
152
|
+
// Anonymous user - check anonymous role permissions
|
|
153
|
+
if (requiredPermissions.length > 0) {
|
|
154
|
+
const anonymousPermissions =
|
|
155
|
+
await context.auth.getAnonymousPermissions();
|
|
156
|
+
const hasPermission = requiredPermissions.some((p: string) =>
|
|
157
|
+
anonymousPermissions.includes(p)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (!hasPermission) {
|
|
161
|
+
throw new ORPCError("FORBIDDEN", {
|
|
162
|
+
message: `Anonymous access not permitted for this resource`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return nextWithErrorLogging();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. Enforce authentication for user/service/authenticated types
|
|
171
|
+
if (!context.user) {
|
|
172
|
+
throw new ORPCError("UNAUTHORIZED", {
|
|
173
|
+
message: "Authentication required",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const user = context.user;
|
|
178
|
+
|
|
179
|
+
// 4. Enforce user type
|
|
180
|
+
if (requiredUserType === "user" && user.type !== "user") {
|
|
181
|
+
throw new ORPCError("FORBIDDEN", {
|
|
182
|
+
message: "This endpoint is for users only",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (requiredUserType === "service" && user.type !== "service") {
|
|
186
|
+
throw new ORPCError("FORBIDDEN", {
|
|
187
|
+
message: "This endpoint is for services only",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 5. Enforce permissions (for real users and applications)
|
|
192
|
+
if (
|
|
193
|
+
(user.type === "user" || user.type === "application") &&
|
|
194
|
+
requiredPermissions.length > 0
|
|
195
|
+
) {
|
|
196
|
+
const userPermissions = user.permissions || [];
|
|
197
|
+
const hasPermission = requiredPermissions.some(
|
|
198
|
+
(p: string) =>
|
|
199
|
+
userPermissions.includes("*") || userPermissions.includes(p)
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (!hasPermission) {
|
|
203
|
+
throw new ORPCError("FORBIDDEN", {
|
|
204
|
+
message: `Missing permission: ${requiredPermissions.join(" or ")}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Pass through - services are trusted with all permissions
|
|
210
|
+
return nextWithErrorLogging();
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// CONTRACT BUILDER
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Base contract builder with automatic authentication and authorization.
|
|
220
|
+
*
|
|
221
|
+
* All plugin contracts should use this builder. It ensures that:
|
|
222
|
+
* 1. All procedures are authenticated by default
|
|
223
|
+
* 2. User type is enforced based on meta.userType
|
|
224
|
+
* 3. Permissions are enforced based on meta.permissions
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* import { baseContractBuilder } from "@checkstack/backend-api";
|
|
228
|
+
* import { permissions } from "./permissions";
|
|
229
|
+
*
|
|
230
|
+
* const myContract = {
|
|
231
|
+
* // User-only endpoint with specific permission
|
|
232
|
+
* getItems: baseContractBuilder
|
|
233
|
+
* .meta({ userType: "user", permissions: [permissions.myPluginRead.id] })
|
|
234
|
+
* .output(z.array(ItemSchema)),
|
|
235
|
+
*
|
|
236
|
+
* // Service-only endpoint (backend-to-backend)
|
|
237
|
+
* internalSync: baseContractBuilder
|
|
238
|
+
* .meta({ userType: "service" })
|
|
239
|
+
* .input(z.object({ data: z.string() }))
|
|
240
|
+
* .output(z.object({ success: z.boolean() })),
|
|
241
|
+
*
|
|
242
|
+
* // Public authenticated endpoint (both users and services)
|
|
243
|
+
* getPublicInfo: baseContractBuilder
|
|
244
|
+
* .meta({ userType: "authenticated" })
|
|
245
|
+
* .output(z.object({ info: z.string() })),
|
|
246
|
+
* };
|
|
247
|
+
*/
|
|
248
|
+
export const baseContractBuilder = os.use(autoAuthMiddleware).meta({});
|
|
249
|
+
|
|
250
|
+
// =============================================================================
|
|
251
|
+
// RPC SERVICE INTERFACE
|
|
252
|
+
// =============================================================================
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Service interface for the RPC registry.
|
|
256
|
+
*/
|
|
257
|
+
export interface RpcService {
|
|
258
|
+
/**
|
|
259
|
+
* Registers an oRPC router and its contract for this plugin.
|
|
260
|
+
* Routes are automatically prefixed with /api/{pluginName}/
|
|
261
|
+
* The contract is used for OpenAPI generation.
|
|
262
|
+
* @param router - The oRPC router instance
|
|
263
|
+
* @param contract - The oRPC contract definition (from *-common package)
|
|
264
|
+
*/
|
|
265
|
+
registerRouter<C extends AnyContractRouter>(
|
|
266
|
+
router: Router<C, RpcContext>,
|
|
267
|
+
contract: C
|
|
268
|
+
): void;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Registers a raw HTTP handler for this plugin.
|
|
272
|
+
* Routes are automatically prefixed with /api/{pluginName}/
|
|
273
|
+
* This is useful for third-party libraries that provide their own handlers (e.g. Better Auth).
|
|
274
|
+
* @param handler - The HTTP request handler
|
|
275
|
+
* @param path - Optional path within plugin namespace (defaults to "/")
|
|
276
|
+
*/
|
|
277
|
+
registerHttpHandler(
|
|
278
|
+
handler: (req: Request) => Promise<Response>,
|
|
279
|
+
path?: string
|
|
280
|
+
): void;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export { z as zod } from "zod";
|
|
284
|
+
export * from "./contract";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getConfigMeta } from "./zod-config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds x-secret, x-color, x-options-resolver, x-depends-on, x-searchable, and x-hidden
|
|
6
|
+
* metadata to JSON Schema based on registry metadata.
|
|
7
|
+
* This is used internally by toJsonSchema.
|
|
8
|
+
* Recursively processes nested objects and arrays.
|
|
9
|
+
*/
|
|
10
|
+
function addSchemaMetadata(
|
|
11
|
+
zodSchema: z.ZodTypeAny,
|
|
12
|
+
jsonSchema: Record<string, unknown>
|
|
13
|
+
): void {
|
|
14
|
+
// Handle arrays - recurse into items
|
|
15
|
+
if (zodSchema instanceof z.ZodArray) {
|
|
16
|
+
const itemsSchema = (zodSchema as z.ZodArray<z.ZodTypeAny>).element;
|
|
17
|
+
const jsonItems = jsonSchema.items as Record<string, unknown> | undefined;
|
|
18
|
+
if (jsonItems) {
|
|
19
|
+
addSchemaMetadata(itemsSchema, jsonItems);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle optional - unwrap and recurse
|
|
25
|
+
if (zodSchema instanceof z.ZodOptional) {
|
|
26
|
+
const innerSchema = zodSchema.unwrap() as z.ZodTypeAny;
|
|
27
|
+
addSchemaMetadata(innerSchema, jsonSchema);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Type guard to check if this is an object schema
|
|
32
|
+
if (!("shape" in zodSchema)) return;
|
|
33
|
+
|
|
34
|
+
const objectSchema = zodSchema as z.ZodObject<z.ZodRawShape>;
|
|
35
|
+
const properties = jsonSchema.properties as
|
|
36
|
+
| Record<string, Record<string, unknown>>
|
|
37
|
+
| undefined;
|
|
38
|
+
|
|
39
|
+
if (!properties) return;
|
|
40
|
+
|
|
41
|
+
for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
|
|
42
|
+
const zodField = fieldSchema as z.ZodTypeAny;
|
|
43
|
+
const jsonField = properties[key];
|
|
44
|
+
|
|
45
|
+
if (!jsonField) continue;
|
|
46
|
+
|
|
47
|
+
// Get metadata from registry
|
|
48
|
+
const meta = getConfigMeta(zodField);
|
|
49
|
+
if (meta) {
|
|
50
|
+
if (meta["x-secret"]) jsonField["x-secret"] = true;
|
|
51
|
+
if (meta["x-color"]) jsonField["x-color"] = true;
|
|
52
|
+
if (meta["x-hidden"]) jsonField["x-hidden"] = true;
|
|
53
|
+
if (meta["x-options-resolver"]) {
|
|
54
|
+
jsonField["x-options-resolver"] = meta["x-options-resolver"];
|
|
55
|
+
if (meta["x-depends-on"])
|
|
56
|
+
jsonField["x-depends-on"] = meta["x-depends-on"];
|
|
57
|
+
if (meta["x-searchable"]) jsonField["x-searchable"] = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Recurse into nested objects and arrays
|
|
62
|
+
addSchemaMetadata(zodField, jsonField);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Converts a Zod schema to JSON Schema with automatic registry metadata.
|
|
68
|
+
* Uses Zod v4's native toJSONSchema() method.
|
|
69
|
+
*
|
|
70
|
+
* The registry metadata enables DynamicForm to automatically render
|
|
71
|
+
* specialized input fields (password for secrets, color picker for colors,
|
|
72
|
+
* dropdowns for optionsResolver fields, hidden for auto-populated fields).
|
|
73
|
+
*/
|
|
74
|
+
export function toJsonSchema(zodSchema: z.ZodTypeAny): Record<string, unknown> {
|
|
75
|
+
// Use Zod's native JSON Schema conversion
|
|
76
|
+
const jsonSchema = zodSchema.toJSONSchema() as Record<string, unknown>;
|
|
77
|
+
addSchemaMetadata(zodSchema, jsonSchema);
|
|
78
|
+
return jsonSchema;
|
|
79
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { mock } from "bun:test";
|
|
2
|
+
import { RpcContext, EmitHookFn } from "./rpc";
|
|
3
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
import { HealthCheckRegistry } from "./health-check";
|
|
5
|
+
import {
|
|
6
|
+
QueuePluginRegistry,
|
|
7
|
+
QueueManager,
|
|
8
|
+
} from "@checkstack/queue-api";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a mocked oRPC context for testing.
|
|
12
|
+
*/
|
|
13
|
+
export function createMockRpcContext(
|
|
14
|
+
overrides: Partial<RpcContext> = {}
|
|
15
|
+
): RpcContext {
|
|
16
|
+
return {
|
|
17
|
+
pluginMetadata: { pluginId: "test-plugin" },
|
|
18
|
+
db: mock() as unknown as NodePgDatabase<Record<string, unknown>>,
|
|
19
|
+
logger: {
|
|
20
|
+
info: mock(),
|
|
21
|
+
error: mock(),
|
|
22
|
+
warn: mock(),
|
|
23
|
+
debug: mock(),
|
|
24
|
+
},
|
|
25
|
+
fetch: {
|
|
26
|
+
fetch: mock(),
|
|
27
|
+
forPlugin: mock().mockReturnValue({
|
|
28
|
+
fetch: mock(),
|
|
29
|
+
get: mock(),
|
|
30
|
+
post: mock(),
|
|
31
|
+
put: mock(),
|
|
32
|
+
patch: mock(),
|
|
33
|
+
delete: mock(),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
auth: {
|
|
37
|
+
authenticate: mock(),
|
|
38
|
+
getCredentials: mock().mockResolvedValue({ headers: {} }),
|
|
39
|
+
getAnonymousPermissions: mock().mockResolvedValue([]),
|
|
40
|
+
},
|
|
41
|
+
healthCheckRegistry: {
|
|
42
|
+
registerStrategy: mock(),
|
|
43
|
+
getStrategies: mock().mockReturnValue([]),
|
|
44
|
+
getStrategy: mock(),
|
|
45
|
+
} as unknown as HealthCheckRegistry,
|
|
46
|
+
queuePluginRegistry: {
|
|
47
|
+
register: mock(),
|
|
48
|
+
getPlugin: mock(),
|
|
49
|
+
getPlugins: mock().mockReturnValue([]),
|
|
50
|
+
} as unknown as QueuePluginRegistry,
|
|
51
|
+
queueManager: {
|
|
52
|
+
getQueue: mock(),
|
|
53
|
+
getActivePlugin: mock(),
|
|
54
|
+
getActiveConfig: mock(),
|
|
55
|
+
setActiveBackend: mock(),
|
|
56
|
+
getInFlightJobCount: mock(),
|
|
57
|
+
listAllRecurringJobs: mock(),
|
|
58
|
+
startPolling: mock(),
|
|
59
|
+
shutdown: mock(),
|
|
60
|
+
} as unknown as QueueManager,
|
|
61
|
+
user: undefined,
|
|
62
|
+
emitHook: mock() as unknown as EmitHookFn,
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ZodSchema } from "zod";
|
|
2
|
+
import { ClientDefinition, InferClient } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
export interface Logger {
|
|
5
|
+
info(message: string, ...args: unknown[]): void;
|
|
6
|
+
error(message: string, ...args: unknown[]): void;
|
|
7
|
+
warn(message: string, ...args: unknown[]): void;
|
|
8
|
+
debug(message: string, ...args: unknown[]): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Fetch {
|
|
12
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
13
|
+
forPlugin(pluginId: string): {
|
|
14
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
15
|
+
get(path: string, init?: RequestInit): Promise<Response>;
|
|
16
|
+
post(path: string, body?: unknown, init?: RequestInit): Promise<Response>;
|
|
17
|
+
put(path: string, body?: unknown, init?: RequestInit): Promise<Response>;
|
|
18
|
+
patch(path: string, body?: unknown, init?: RequestInit): Promise<Response>;
|
|
19
|
+
delete(path: string, init?: RequestInit): Promise<Response>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Real user authenticated via session/token (human users).
|
|
25
|
+
* Has permissions and roles from the RBAC system.
|
|
26
|
+
*/
|
|
27
|
+
export interface RealUser {
|
|
28
|
+
type: "user";
|
|
29
|
+
id: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
permissions?: string[];
|
|
33
|
+
roles?: string[];
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Service user for backend-to-backend calls.
|
|
39
|
+
* Trusted implicitly - no permissions/roles needed.
|
|
40
|
+
*/
|
|
41
|
+
export interface ServiceUser {
|
|
42
|
+
type: "service";
|
|
43
|
+
pluginId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* External application authenticated via API key.
|
|
48
|
+
* Has permissions and roles from the RBAC system like RealUser.
|
|
49
|
+
*/
|
|
50
|
+
export interface ApplicationUser {
|
|
51
|
+
type: "application";
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
permissions?: string[];
|
|
55
|
+
roles?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discriminated union of user types.
|
|
60
|
+
* Use `user.type` to discriminate between real users, services, and applications.
|
|
61
|
+
*/
|
|
62
|
+
export type AuthUser = RealUser | ServiceUser | ApplicationUser;
|
|
63
|
+
|
|
64
|
+
export interface AuthService {
|
|
65
|
+
authenticate(request: Request): Promise<AuthUser | undefined>;
|
|
66
|
+
getCredentials(): Promise<{ headers: Record<string, string> }>;
|
|
67
|
+
/**
|
|
68
|
+
* Get permissions assigned to the anonymous role.
|
|
69
|
+
* Used by autoAuthMiddleware to check permissions for unauthenticated
|
|
70
|
+
* users on "public" userType endpoints.
|
|
71
|
+
*/
|
|
72
|
+
getAnonymousPermissions(): Promise<string[]>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Authentication strategy for validating user credentials.
|
|
77
|
+
* Returns RealUser for human users or ApplicationUser for API keys.
|
|
78
|
+
*/
|
|
79
|
+
export interface AuthenticationStrategy {
|
|
80
|
+
validate(request: Request): Promise<RealUser | ApplicationUser | undefined>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PluginInstaller {
|
|
84
|
+
install(packageName: string): Promise<{ name: string; path: string }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Options for declarative route definitions (Deprecated, will be replaced by oRPC procedures).
|
|
89
|
+
*/
|
|
90
|
+
export interface RouteOptions {
|
|
91
|
+
permission?: string | string[];
|
|
92
|
+
schema?: ZodSchema;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* RPC Client for typed backend-to-backend communication.
|
|
97
|
+
* Similar to the frontend RpcApi but with service token authentication.
|
|
98
|
+
*/
|
|
99
|
+
export interface RpcClient {
|
|
100
|
+
/**
|
|
101
|
+
* Get a typed RPC client for a specific plugin.
|
|
102
|
+
* @param def - The client definition from the target plugin's common package
|
|
103
|
+
* @returns Typed client for the plugin's RPC endpoints
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* import { AuthApi } from "@checkstack/auth-common";
|
|
107
|
+
* const authClient = rpcClient.forPlugin(AuthApi);
|
|
108
|
+
* const result = await authClient.getRegistrationStatus();
|
|
109
|
+
*/
|
|
110
|
+
forPlugin<T extends ClientDefinition>(def: T): InferClient<T>;
|
|
111
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// CONFIG REGISTRY - Typed metadata for configuration schemas
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Metadata type for configuration schemas.
|
|
9
|
+
* Provides autocompletion for `.meta()` calls on config fields.
|
|
10
|
+
*/
|
|
11
|
+
export interface ConfigMeta {
|
|
12
|
+
/** Mark as a secret field (password input, encrypted storage, redacted in UI) */
|
|
13
|
+
"x-secret"?: boolean;
|
|
14
|
+
/** Mark as a color field (color picker input) */
|
|
15
|
+
"x-color"?: boolean;
|
|
16
|
+
/** Mark as hidden (auto-populated, not shown in forms) */
|
|
17
|
+
"x-hidden"?: boolean;
|
|
18
|
+
/** Name of the resolver function for dynamic options dropdown */
|
|
19
|
+
"x-options-resolver"?: string;
|
|
20
|
+
/** Field names this field depends on (triggers refetch when they change) */
|
|
21
|
+
"x-depends-on"?: string[];
|
|
22
|
+
/** If true, renders a searchable/filterable dropdown */
|
|
23
|
+
"x-searchable"?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registry for config schema metadata.
|
|
28
|
+
* Used by schema-utils.ts and config-service.ts for field detection.
|
|
29
|
+
*/
|
|
30
|
+
export const configRegistry = z.registry<ConfigMeta>();
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// SCHEMA UNWRAPPING - Handle Optional/Default/Nullable wrappers
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unwraps a Zod schema to get the inner schema, handling:
|
|
38
|
+
* - ZodOptional
|
|
39
|
+
* - ZodDefault
|
|
40
|
+
* - ZodNullable
|
|
41
|
+
*/
|
|
42
|
+
function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
43
|
+
let unwrapped = schema;
|
|
44
|
+
|
|
45
|
+
if (unwrapped instanceof z.ZodOptional) {
|
|
46
|
+
unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (unwrapped instanceof z.ZodDefault) {
|
|
50
|
+
unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (unwrapped instanceof z.ZodNullable) {
|
|
54
|
+
unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return unwrapped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get config metadata for a schema.
|
|
62
|
+
* Automatically unwraps Optional/Default/Nullable wrappers.
|
|
63
|
+
*/
|
|
64
|
+
export function getConfigMeta(schema: z.ZodTypeAny): ConfigMeta | undefined {
|
|
65
|
+
return configRegistry.get(unwrapSchema(schema));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a schema has secret metadata.
|
|
70
|
+
*/
|
|
71
|
+
export function isSecretSchema(schema: z.ZodTypeAny): boolean {
|
|
72
|
+
return getConfigMeta(schema)?.["x-secret"] === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a schema has color metadata.
|
|
77
|
+
*/
|
|
78
|
+
export function isColorSchema(schema: z.ZodTypeAny): boolean {
|
|
79
|
+
return getConfigMeta(schema)?.["x-color"] === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a schema has hidden metadata.
|
|
84
|
+
*/
|
|
85
|
+
export function isHiddenSchema(schema: z.ZodTypeAny): boolean {
|
|
86
|
+
return getConfigMeta(schema)?.["x-hidden"] === true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get options resolver metadata for a schema.
|
|
91
|
+
*/
|
|
92
|
+
export function getOptionsResolverMetadata(
|
|
93
|
+
schema: z.ZodTypeAny
|
|
94
|
+
):
|
|
95
|
+
| { resolver: string; dependsOn?: string[]; searchable?: boolean }
|
|
96
|
+
| undefined {
|
|
97
|
+
const meta = getConfigMeta(schema);
|
|
98
|
+
if (!meta?.["x-options-resolver"]) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
resolver: meta["x-options-resolver"],
|
|
101
|
+
dependsOn: meta["x-depends-on"],
|
|
102
|
+
searchable: meta["x-searchable"],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// TYPED ZOD CONFIG - Uses .register() directly instead of overriding .meta()
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a config string field with typed metadata.
|
|
112
|
+
* Registers metadata in configRegistry for detection by schema-utils and config-service.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```typescript
|
|
116
|
+
* import { configString } from "@checkstack/backend-api";
|
|
117
|
+
*
|
|
118
|
+
* const schema = z.object({
|
|
119
|
+
* apiToken: configString({ "x-secret": true }).describe("API Token"),
|
|
120
|
+
* projectKey: configString({
|
|
121
|
+
* "x-options-resolver": "projectOptions",
|
|
122
|
+
* "x-depends-on": ["connectionId"],
|
|
123
|
+
* }),
|
|
124
|
+
* });
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function configString(meta: ConfigMeta) {
|
|
128
|
+
const schema = z.string();
|
|
129
|
+
schema.register(configRegistry, meta);
|
|
130
|
+
return schema;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a config number field with typed metadata.
|
|
135
|
+
*/
|
|
136
|
+
export function configNumber(meta: ConfigMeta) {
|
|
137
|
+
const schema = z.number();
|
|
138
|
+
schema.register(configRegistry, meta);
|
|
139
|
+
return schema;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a config boolean field with typed metadata.
|
|
144
|
+
*/
|
|
145
|
+
export function configBoolean(meta: ConfigMeta) {
|
|
146
|
+
const schema = z.boolean();
|
|
147
|
+
schema.register(configRegistry, meta);
|
|
148
|
+
return schema;
|
|
149
|
+
}
|