@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/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,15 @@
1
+ export type ServiceRef<T> = {
2
+ id: string;
3
+ T: T;
4
+ toString(): string;
5
+ };
6
+
7
+ export function createServiceRef<T>(id: string): ServiceRef<T> {
8
+ return {
9
+ id,
10
+ T: undefined as T,
11
+ toString() {
12
+ return `ServiceRef(${id})`;
13
+ },
14
+ };
15
+ }
@@ -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
+ }