@checkstack/common 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 +54 -0
- package/package.json +29 -0
- package/src/client-definition.ts +71 -0
- package/src/icons.ts +26 -0
- package/src/index.ts +7 -0
- package/src/pagination.test.ts +89 -0
- package/src/pagination.ts +55 -0
- package/src/permission-utils.ts +81 -0
- package/src/plugin-metadata.ts +28 -0
- package/src/routes.ts +139 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @checkstack/common
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- a65e002: Add compile-time type safety for Lucide icon names
|
|
14
|
+
|
|
15
|
+
- Add `LucideIconName` type and `lucideIconSchema` Zod schema to `@checkstack/common`
|
|
16
|
+
- Update backend interfaces (`AuthStrategy`, `NotificationStrategy`, `IntegrationProvider`, `CommandDefinition`) to use `LucideIconName`
|
|
17
|
+
- Update RPC contracts to use `lucideIconSchema` for proper type inference across RPC boundaries
|
|
18
|
+
- Simplify `SocialProviderButton` to use `DynamicIcon` directly (removes 30+ lines of pascalCase conversion)
|
|
19
|
+
- Replace static `iconMap` in `SearchDialog` with `DynamicIcon` for dynamic icon rendering
|
|
20
|
+
- Add fallback handling in `DynamicIcon` when icon name isn't found
|
|
21
|
+
- Fix legacy kebab-case icon names to PascalCase: `mail`→`Mail`, `send`→`Send`, `github`→`Github`, `key-round`→`KeyRound`, `network`→`Network`, `AlertCircle`→`CircleAlert`
|
|
22
|
+
|
|
23
|
+
## 0.1.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- ffc28f6: ### Anonymous Role and Public Access
|
|
28
|
+
|
|
29
|
+
Introduces a configurable "anonymous" role for managing permissions available to unauthenticated users.
|
|
30
|
+
|
|
31
|
+
**Core Changes:**
|
|
32
|
+
|
|
33
|
+
- Added `userType: "public"` - endpoints accessible by both authenticated users (with their permissions) and anonymous users (with anonymous role permissions)
|
|
34
|
+
- Renamed `userType: "both"` to `"authenticated"` for clarity
|
|
35
|
+
- Renamed `isDefault` to `isAuthenticatedDefault` on Permission interface
|
|
36
|
+
- Added `isPublicDefault` flag for permissions that should be granted to the anonymous role by default
|
|
37
|
+
|
|
38
|
+
**Backend Infrastructure:**
|
|
39
|
+
|
|
40
|
+
- New `anonymous` system role created during auth-backend initialization
|
|
41
|
+
- New `disabled_public_default_permission` table tracks admin-disabled public defaults
|
|
42
|
+
- `autoAuthMiddleware` now checks anonymous role permissions for unauthenticated public endpoint access
|
|
43
|
+
- `AuthService.getAnonymousPermissions()` with 1-minute caching for performance
|
|
44
|
+
- Anonymous role filtered from `getRoles` endpoint (not assignable to users)
|
|
45
|
+
- Validation prevents assigning anonymous role to users
|
|
46
|
+
|
|
47
|
+
**Catalog Integration:**
|
|
48
|
+
|
|
49
|
+
- `catalog.read` permission now has both `isAuthenticatedDefault` and `isPublicDefault`
|
|
50
|
+
- Read endpoints (`getSystems`, `getGroups`, `getEntities`) now use `userType: "public"`
|
|
51
|
+
|
|
52
|
+
**UI:**
|
|
53
|
+
|
|
54
|
+
- New `PermissionGate` component for conditionally rendering content based on permissions
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/common",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"require": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@orpc/contract": "^1.5.0",
|
|
16
|
+
"lucide-react": "0.562.0",
|
|
17
|
+
"zod": "^4.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.7.2",
|
|
21
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
22
|
+
"@checkstack/scripts": "workspace:*"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint": "bun run lint:code",
|
|
27
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ContractRouterClient, AnyContractRouter } from "@orpc/contract";
|
|
2
|
+
import type { PluginMetadata } from "./plugin-metadata";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A client definition that bundles an RPC contract type with its plugin metadata.
|
|
6
|
+
* Used for type-safe plugin RPC consumption.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* // Define in auth-common
|
|
11
|
+
* export const AuthApi = createClientDefinition(authContract, pluginMetadata);
|
|
12
|
+
*
|
|
13
|
+
* // Use in frontend/backend
|
|
14
|
+
* const authClient = rpcApi.forPlugin(AuthApi); // Fully typed!
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export interface ClientDefinition<
|
|
18
|
+
TContract extends AnyContractRouter = AnyContractRouter
|
|
19
|
+
> {
|
|
20
|
+
readonly pluginId: string;
|
|
21
|
+
/**
|
|
22
|
+
* Phantom type for contract type inference.
|
|
23
|
+
* This property doesn't exist at runtime - it's only used by TypeScript
|
|
24
|
+
* to carry the contract type information for type inference.
|
|
25
|
+
*/
|
|
26
|
+
readonly __contractType?: TContract;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Type helper to extract the client type from a ClientDefinition.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* type AuthClient = InferClient<typeof AuthApi>;
|
|
35
|
+
* // Equivalent to: ContractRouterClient<typeof authContract>
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export type InferClient<T extends ClientDefinition> =
|
|
39
|
+
T extends ClientDefinition<infer C> ? ContractRouterClient<C> : never;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a typed client definition for a plugin's RPC contract.
|
|
43
|
+
*
|
|
44
|
+
* This bundles the contract type with the plugin metadata, enabling type-safe
|
|
45
|
+
* forPlugin() calls without manual type annotations.
|
|
46
|
+
*
|
|
47
|
+
* @param _contract - The RPC contract object (used only for type inference)
|
|
48
|
+
* @param metadata - The plugin metadata from the plugin's plugin-metadata.ts
|
|
49
|
+
* @returns A ClientDefinition object that can be passed to forPlugin()
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // In @checkstack/auth-common
|
|
54
|
+
* import { authContract } from "./rpc-contract";
|
|
55
|
+
* import { pluginMetadata } from "./plugin-metadata";
|
|
56
|
+
*
|
|
57
|
+
* export const AuthApi = createClientDefinition(authContract, pluginMetadata);
|
|
58
|
+
*
|
|
59
|
+
* // In consumer (frontend or backend)
|
|
60
|
+
* const authClient = rpcApi.forPlugin(AuthApi);
|
|
61
|
+
* await authClient.getUsers(); // Fully typed!
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function createClientDefinition<TContract extends AnyContractRouter>(
|
|
65
|
+
_contract: TContract,
|
|
66
|
+
metadata: PluginMetadata
|
|
67
|
+
): ClientDefinition<TContract> {
|
|
68
|
+
return {
|
|
69
|
+
pluginId: metadata.pluginId,
|
|
70
|
+
} as ClientDefinition<TContract>;
|
|
71
|
+
}
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lucide icon name type and Zod schema.
|
|
3
|
+
* This type is derived from the 'lucide-react' package icons export.
|
|
4
|
+
* Uses type-only import to avoid React runtime dependency on the backend.
|
|
5
|
+
*
|
|
6
|
+
* Icon names are PascalCase (e.g., 'CircleAlert', 'HeartPulse', 'Users')
|
|
7
|
+
*/
|
|
8
|
+
import type { icons } from "lucide-react";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Valid Lucide icon names (PascalCase).
|
|
13
|
+
* @example "CircleAlert", "Settings", "Users"
|
|
14
|
+
*/
|
|
15
|
+
export type LucideIconName = keyof typeof icons;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Zod schema for LucideIconName.
|
|
19
|
+
* Uses string at runtime but infers LucideIconName type for compile-time safety.
|
|
20
|
+
* Use this in RPC contracts to get proper type inference.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const schema = z.object({ icon: lucideIconSchema.optional() });
|
|
24
|
+
* // Infers: { icon?: LucideIconName }
|
|
25
|
+
*/
|
|
26
|
+
export const lucideIconSchema = z.string() as z.ZodType<LucideIconName>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { PaginationInputSchema, paginatedOutput } from "./pagination";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
describe("PaginationInputSchema", () => {
|
|
6
|
+
it("should accept valid pagination input", () => {
|
|
7
|
+
const result = PaginationInputSchema.parse({
|
|
8
|
+
limit: 20,
|
|
9
|
+
offset: 40,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.limit).toBe(20);
|
|
13
|
+
expect(result.offset).toBe(40);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should use default values when not provided", () => {
|
|
17
|
+
const result = PaginationInputSchema.parse({});
|
|
18
|
+
|
|
19
|
+
expect(result.limit).toBe(10);
|
|
20
|
+
expect(result.offset).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should reject limit below 1", () => {
|
|
24
|
+
expect(() => PaginationInputSchema.parse({ limit: 0 })).toThrow();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should reject limit above 100", () => {
|
|
28
|
+
expect(() => PaginationInputSchema.parse({ limit: 101 })).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should reject negative offset", () => {
|
|
32
|
+
expect(() => PaginationInputSchema.parse({ offset: -1 })).toThrow();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("paginatedOutput", () => {
|
|
37
|
+
const ItemSchema = z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
name: z.string(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should create correct output schema structure", () => {
|
|
43
|
+
const outputSchema = paginatedOutput(ItemSchema);
|
|
44
|
+
|
|
45
|
+
const result = outputSchema.parse({
|
|
46
|
+
items: [
|
|
47
|
+
{ id: "1", name: "Item 1" },
|
|
48
|
+
{ id: "2", name: "Item 2" },
|
|
49
|
+
],
|
|
50
|
+
total: 100,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.items).toHaveLength(2);
|
|
54
|
+
expect(result.total).toBe(100);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should reject invalid items", () => {
|
|
58
|
+
const outputSchema = paginatedOutput(ItemSchema);
|
|
59
|
+
|
|
60
|
+
expect(() =>
|
|
61
|
+
outputSchema.parse({
|
|
62
|
+
items: [{ id: "1" }], // Missing 'name'
|
|
63
|
+
total: 1,
|
|
64
|
+
})
|
|
65
|
+
).toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should reject missing total", () => {
|
|
69
|
+
const outputSchema = paginatedOutput(ItemSchema);
|
|
70
|
+
|
|
71
|
+
expect(() =>
|
|
72
|
+
outputSchema.parse({
|
|
73
|
+
items: [{ id: "1", name: "Item 1" }],
|
|
74
|
+
})
|
|
75
|
+
).toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should accept empty items array", () => {
|
|
79
|
+
const outputSchema = paginatedOutput(ItemSchema);
|
|
80
|
+
|
|
81
|
+
const result = outputSchema.parse({
|
|
82
|
+
items: [],
|
|
83
|
+
total: 0,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.items).toEqual([]);
|
|
87
|
+
expect(result.total).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standard pagination input schema for RPC procedures.
|
|
5
|
+
* Use with paginatedOutput() for consistent pagination patterns.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // In your contract:
|
|
10
|
+
* import { PaginationInputSchema, paginatedOutput } from "@checkstack/common";
|
|
11
|
+
*
|
|
12
|
+
* const contract = {
|
|
13
|
+
* getItems: _base
|
|
14
|
+
* .input(PaginationInputSchema.extend({ search: z.string().optional() }))
|
|
15
|
+
* .output(paginatedOutput(ItemSchema)),
|
|
16
|
+
* };
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const PaginationInputSchema = z.object({
|
|
20
|
+
/** Number of items per page (1-100) */
|
|
21
|
+
limit: z.number().min(1).max(100).default(10),
|
|
22
|
+
/** Number of items to skip */
|
|
23
|
+
offset: z.number().min(0).default(0),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a paginated output schema wrapper.
|
|
30
|
+
* Returns { items: T[], total: number } structure that works with usePagination hook.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* // Contract definition:
|
|
35
|
+
* getUsers: _base
|
|
36
|
+
* .input(PaginationInputSchema)
|
|
37
|
+
* .output(paginatedOutput(UserSchema)),
|
|
38
|
+
*
|
|
39
|
+
* // Returns: { items: User[], total: number }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function paginatedOutput<T extends z.ZodTypeAny>(itemSchema: T) {
|
|
43
|
+
return z.object({
|
|
44
|
+
items: z.array(itemSchema),
|
|
45
|
+
total: z.number(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Type helper for paginated responses
|
|
51
|
+
*/
|
|
52
|
+
export type PaginatedResponse<T> = {
|
|
53
|
+
items: T[];
|
|
54
|
+
total: number;
|
|
55
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { PluginMetadata } from "./plugin-metadata";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supported actions for permissions.
|
|
5
|
+
*/
|
|
6
|
+
export type PermissionAction = "read" | "manage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Represents a permission that can be assigned to roles.
|
|
10
|
+
*/
|
|
11
|
+
export interface Permission {
|
|
12
|
+
/** Permission identifier (e.g., "catalog.read") */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human-readable description of what this permission allows */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Whether this permission is assigned to the default "users" role (authenticated users) */
|
|
17
|
+
isAuthenticatedDefault?: boolean;
|
|
18
|
+
/** Whether this permission is assigned to the "anonymous" role (public access) */
|
|
19
|
+
isPublicDefault?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents a permission tied to a specific resource and action.
|
|
24
|
+
*/
|
|
25
|
+
export interface ResourcePermission extends Permission {
|
|
26
|
+
/** The resource this permission applies to */
|
|
27
|
+
resource: string;
|
|
28
|
+
/** The action allowed on the resource */
|
|
29
|
+
action: PermissionAction;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Helper to create a standardized resource permission.
|
|
34
|
+
*
|
|
35
|
+
* @param resource The resource name (e.g., "catalog", "healthcheck")
|
|
36
|
+
* @param action The action (e.g., "read", "manage")
|
|
37
|
+
* @param description Optional human-readable description
|
|
38
|
+
* @param options Additional options like isAuthenticatedDefault and isPublicDefault
|
|
39
|
+
*/
|
|
40
|
+
export function createPermission(
|
|
41
|
+
resource: string,
|
|
42
|
+
action: PermissionAction,
|
|
43
|
+
description?: string,
|
|
44
|
+
options?: { isAuthenticatedDefault?: boolean; isPublicDefault?: boolean }
|
|
45
|
+
): ResourcePermission {
|
|
46
|
+
return {
|
|
47
|
+
id: `${resource}.${action}`,
|
|
48
|
+
resource,
|
|
49
|
+
action,
|
|
50
|
+
description,
|
|
51
|
+
isAuthenticatedDefault: options?.isAuthenticatedDefault,
|
|
52
|
+
isPublicDefault: options?.isPublicDefault,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a fully-qualified permission ID by prefixing the permission's ID with the plugin ID.
|
|
58
|
+
*
|
|
59
|
+
* This is the canonical way to construct namespaced permission IDs for authorization checks.
|
|
60
|
+
* The function ensures consistent formatting across all permission-related operations.
|
|
61
|
+
*
|
|
62
|
+
* @param pluginMetadata - The plugin metadata containing the pluginId
|
|
63
|
+
* @param permission - The permission object containing the local permission ID
|
|
64
|
+
* @returns The fully-qualified permission ID (e.g., "catalog.catalog.read")
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* import { qualifyPermissionId } from "@checkstack/common";
|
|
69
|
+
* import { pluginMetadata } from "./plugin-metadata";
|
|
70
|
+
* import { permissions } from "./permissions";
|
|
71
|
+
*
|
|
72
|
+
* const qualifiedId = qualifyPermissionId(pluginMetadata, permissions.catalogRead);
|
|
73
|
+
* // Returns: "catalog.catalog.read"
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function qualifyPermissionId(
|
|
77
|
+
pluginMetadata: Pick<PluginMetadata, "pluginId">,
|
|
78
|
+
permission: Pick<Permission, "id">
|
|
79
|
+
): string {
|
|
80
|
+
return `${pluginMetadata.pluginId}.${permission.id}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin metadata interface for backend plugins.
|
|
3
|
+
*
|
|
4
|
+
* Each backend plugin should export a `pluginMetadata` object from `plugin-metadata.ts`
|
|
5
|
+
* that implements this interface. This provides a single source of truth for:
|
|
6
|
+
* - The pluginId (used by createBackendPlugin and drizzle schema generation)
|
|
7
|
+
* - Other plugin metadata that may be needed at build time
|
|
8
|
+
*/
|
|
9
|
+
export interface PluginMetadata {
|
|
10
|
+
/** The unique identifier for this plugin */
|
|
11
|
+
pluginId: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Previous plugin IDs that this plugin was known by.
|
|
15
|
+
* Used during schema migrations to rename old schemas to the new name.
|
|
16
|
+
* Only needed when renaming a plugin that has already been deployed.
|
|
17
|
+
*/
|
|
18
|
+
previousPluginIds?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Helper function to create typed plugin metadata.
|
|
23
|
+
* @param metadata The plugin metadata object
|
|
24
|
+
* @returns The same object with proper typing
|
|
25
|
+
*/
|
|
26
|
+
export function definePluginMetadata<T extends PluginMetadata>(metadata: T): T {
|
|
27
|
+
return metadata;
|
|
28
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route definition with path and extracted parameters.
|
|
3
|
+
*/
|
|
4
|
+
export interface RouteDefinition<TParams extends string = string> {
|
|
5
|
+
/** Unique route identifier (pluginId.routeName) */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Plugin identifier */
|
|
8
|
+
pluginId: string;
|
|
9
|
+
/** Relative path with optional :param placeholders */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Parameter names extracted from path */
|
|
12
|
+
params: TParams[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Plugin routes configuration object.
|
|
17
|
+
*/
|
|
18
|
+
export interface PluginRoutes<
|
|
19
|
+
T extends Record<string, RouteDefinition<string>> = Record<
|
|
20
|
+
string,
|
|
21
|
+
RouteDefinition<string>
|
|
22
|
+
>
|
|
23
|
+
> {
|
|
24
|
+
pluginId: string;
|
|
25
|
+
routes: T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract parameter names from a path string.
|
|
30
|
+
* E.g., "/detail/:id" -> ["id"]
|
|
31
|
+
*/
|
|
32
|
+
type ExtractParams<T extends string> =
|
|
33
|
+
T extends `${string}:${infer Param}/${infer Rest}`
|
|
34
|
+
? Param | ExtractParams<Rest>
|
|
35
|
+
: T extends `${string}:${infer Param}`
|
|
36
|
+
? Param
|
|
37
|
+
: never;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates a typed plugin routes configuration.
|
|
41
|
+
* Routes are relative paths that will be prefixed with /{pluginId} at runtime.
|
|
42
|
+
*
|
|
43
|
+
* @param pluginId - Plugin identifier (without -frontend/-backend suffix)
|
|
44
|
+
* @param routes - Object mapping route names to paths
|
|
45
|
+
* @returns Typed routes object for use in frontend plugins and route resolution
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* // In maintenance-common/src/routes.ts
|
|
50
|
+
* export const maintenanceRoutes = createRoutes("maintenance", {
|
|
51
|
+
* config: "/config",
|
|
52
|
+
* detail: "/detail/:id",
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* // Usage in frontend:
|
|
56
|
+
* routes: [
|
|
57
|
+
* { route: maintenanceRoutes.routes.config, element: <ConfigPage /> },
|
|
58
|
+
* ]
|
|
59
|
+
*
|
|
60
|
+
* // Resolution:
|
|
61
|
+
* resolveRoute(maintenanceRoutes.routes.config) // "/maintenance/config"
|
|
62
|
+
* resolveRoute(maintenanceRoutes.routes.detail, { id: "123" }) // "/maintenance/detail/123"
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function createRoutes<T extends Record<string, string>>(
|
|
66
|
+
pluginId: string,
|
|
67
|
+
routes: T
|
|
68
|
+
): PluginRoutes<{
|
|
69
|
+
[K in keyof T]: RouteDefinition<ExtractParams<T[K]>>;
|
|
70
|
+
}> {
|
|
71
|
+
const routeDefinitions: Record<string, RouteDefinition<string>> = {};
|
|
72
|
+
|
|
73
|
+
for (const [name, path] of Object.entries(routes)) {
|
|
74
|
+
// Extract params from path (e.g., ":id" -> "id")
|
|
75
|
+
const params: string[] = [];
|
|
76
|
+
const paramMatches = path.matchAll(/:([^/]+)/g);
|
|
77
|
+
for (const match of paramMatches) {
|
|
78
|
+
params.push(match[1]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
routeDefinitions[name] = {
|
|
82
|
+
id: `${pluginId}.${name}`,
|
|
83
|
+
pluginId,
|
|
84
|
+
path,
|
|
85
|
+
params,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
pluginId,
|
|
91
|
+
routes: routeDefinitions,
|
|
92
|
+
} as PluginRoutes<{
|
|
93
|
+
[K in keyof T]: RouteDefinition<ExtractParams<T[K]>>;
|
|
94
|
+
}>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves a route definition to a full path with the plugin prefix.
|
|
99
|
+
*
|
|
100
|
+
* @param route - Route definition from a plugin routes object
|
|
101
|
+
* @param params - Path parameters to substitute (required if route has params)
|
|
102
|
+
* @returns The full path with plugin prefix and substituted parameters
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* resolveRoute(maintenanceRoutes.routes.config)
|
|
107
|
+
* // Returns: "/maintenance/config"
|
|
108
|
+
*
|
|
109
|
+
* resolveRoute(maintenanceRoutes.routes.detail, { id: "123" })
|
|
110
|
+
* // Returns: "/maintenance/detail/123"
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
// Overload: no params needed for routes without path parameters
|
|
114
|
+
export function resolveRoute(route: RouteDefinition<never>): string;
|
|
115
|
+
// Overload: params required for routes with path parameters
|
|
116
|
+
export function resolveRoute<TParams extends string>(
|
|
117
|
+
route: RouteDefinition<TParams>,
|
|
118
|
+
params: Record<TParams, string>
|
|
119
|
+
): string;
|
|
120
|
+
// Implementation
|
|
121
|
+
export function resolveRoute<TParams extends string>(
|
|
122
|
+
route: RouteDefinition<TParams>,
|
|
123
|
+
params?: Record<string, string>
|
|
124
|
+
): string {
|
|
125
|
+
const basePath = `/${route.pluginId}${
|
|
126
|
+
route.path.startsWith("/") ? route.path : `/${route.path}`
|
|
127
|
+
}`;
|
|
128
|
+
|
|
129
|
+
if (!params || route.params.length === 0) {
|
|
130
|
+
return basePath;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Substitute path parameters (e.g., :id -> actual value)
|
|
134
|
+
let result = basePath;
|
|
135
|
+
for (const [key, value] of Object.entries(params)) {
|
|
136
|
+
result = result.replace(`:${key}`, value);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// RPC PROCEDURE METADATA
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Metadata interface for RPC procedures.
|
|
7
|
+
* Used by contracts to define auth requirements and by backend middleware to enforce them.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const contract = {
|
|
11
|
+
* getItems: baseContractBuilder
|
|
12
|
+
* .meta({
|
|
13
|
+
* userType: "user",
|
|
14
|
+
* permissions: [permissions.myPluginRead.id]
|
|
15
|
+
* })
|
|
16
|
+
* .output(z.array(ItemSchema)),
|
|
17
|
+
* };
|
|
18
|
+
*/
|
|
19
|
+
export interface ProcedureMetadata {
|
|
20
|
+
/**
|
|
21
|
+
* Which type of caller can access this endpoint.
|
|
22
|
+
* - "anonymous": No authentication required, no permission checks (fully public)
|
|
23
|
+
* - "public": Anyone can attempt, but permissions are checked (uses anonymous role for guests)
|
|
24
|
+
* - "user": Only real users (frontend authenticated)
|
|
25
|
+
* - "service": Only services (backend-to-backend)
|
|
26
|
+
* - "authenticated": Either users or services, but must be authenticated (default)
|
|
27
|
+
*/
|
|
28
|
+
userType?: "anonymous" | "public" | "user" | "service" | "authenticated";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Permissions required to access this endpoint.
|
|
32
|
+
* Only enforced for real users - services are trusted.
|
|
33
|
+
* For "public" userType, permissions are checked against the anonymous role if not authenticated.
|
|
34
|
+
* User must have at least one of the listed permissions, or "*" (wildcard).
|
|
35
|
+
*/
|
|
36
|
+
permissions?: string[];
|
|
37
|
+
}
|