@uploadista/server 0.0.17 → 0.0.18-beta.10

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,139 @@
1
+ /**
2
+ * Permission Matching Logic
3
+ *
4
+ * Implements permission matching with support for:
5
+ * - Exact match: `engine:health` matches `engine:health`
6
+ * - Wildcard match: `engine:*` matches `engine:health`, `engine:metrics`, etc.
7
+ * - Hierarchical match: `engine:dlq` implies `engine:dlq:read` and `engine:dlq:write`
8
+ */
9
+
10
+ import { PERMISSION_HIERARCHY } from "./types";
11
+
12
+ /**
13
+ * Checks if a granted permission matches a required permission.
14
+ *
15
+ * @param granted - The permission that has been granted to the user
16
+ * @param required - The permission that is required for the operation
17
+ * @returns true if the granted permission satisfies the required permission
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * matchesPermission("engine:*", "engine:health") // true (wildcard)
22
+ * matchesPermission("engine:health", "engine:health") // true (exact)
23
+ * matchesPermission("engine:dlq", "engine:dlq:read") // true (hierarchical)
24
+ * matchesPermission("flow:execute", "engine:health") // false
25
+ * ```
26
+ */
27
+ export const matchesPermission = (
28
+ granted: string,
29
+ required: string,
30
+ ): boolean => {
31
+ // Exact match
32
+ if (granted === required) {
33
+ return true;
34
+ }
35
+
36
+ // Wildcard match: `engine:*` matches `engine:health`
37
+ if (granted.endsWith(":*")) {
38
+ const prefix = granted.slice(0, -1); // Remove the `*`, keep the `:`
39
+ if (required.startsWith(prefix)) {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ // Hierarchical match: `engine:dlq` implies `engine:dlq:read`
45
+ const impliedPermissions = PERMISSION_HIERARCHY[granted];
46
+ if (impliedPermissions?.includes(required)) {
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ };
52
+
53
+ /**
54
+ * Checks if any of the granted permissions satisfy the required permission.
55
+ *
56
+ * @param grantedPermissions - Array of permissions granted to the user
57
+ * @param required - The permission that is required for the operation
58
+ * @returns true if any granted permission satisfies the required permission
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * hasPermission(["flow:*", "upload:create"], "flow:execute") // true
63
+ * hasPermission(["upload:create"], "flow:execute") // false
64
+ * ```
65
+ */
66
+ export const hasPermission = (
67
+ grantedPermissions: readonly string[],
68
+ required: string,
69
+ ): boolean => {
70
+ return grantedPermissions.some((granted) =>
71
+ matchesPermission(granted, required),
72
+ );
73
+ };
74
+
75
+ /**
76
+ * Checks if any of the granted permissions satisfy any of the required permissions.
77
+ *
78
+ * @param grantedPermissions - Array of permissions granted to the user
79
+ * @param requiredPermissions - Array of permissions, any of which would be sufficient
80
+ * @returns true if any granted permission satisfies any required permission
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * hasAnyPermission(["upload:create"], ["flow:execute", "upload:create"]) // true
85
+ * hasAnyPermission(["upload:read"], ["flow:execute", "upload:create"]) // false
86
+ * ```
87
+ */
88
+ export const hasAnyPermission = (
89
+ grantedPermissions: readonly string[],
90
+ requiredPermissions: readonly string[],
91
+ ): boolean => {
92
+ return requiredPermissions.some((required) =>
93
+ hasPermission(grantedPermissions, required),
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Checks if all of the required permissions are satisfied.
99
+ *
100
+ * @param grantedPermissions - Array of permissions granted to the user
101
+ * @param requiredPermissions - Array of permissions, all of which must be satisfied
102
+ * @returns true if all required permissions are satisfied
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * hasAllPermissions(["flow:*", "upload:*"], ["flow:execute", "upload:create"]) // true
107
+ * hasAllPermissions(["flow:execute"], ["flow:execute", "upload:create"]) // false
108
+ * ```
109
+ */
110
+ export const hasAllPermissions = (
111
+ grantedPermissions: readonly string[],
112
+ requiredPermissions: readonly string[],
113
+ ): boolean => {
114
+ return requiredPermissions.every((required) =>
115
+ hasPermission(grantedPermissions, required),
116
+ );
117
+ };
118
+
119
+ /**
120
+ * Expands a permission to include all implied permissions.
121
+ * Useful for display or audit purposes.
122
+ *
123
+ * @param permission - The permission to expand
124
+ * @returns Array of the permission and all implied permissions
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * expandPermission("engine:dlq") // ["engine:dlq", "engine:dlq:read", "engine:dlq:write"]
129
+ * expandPermission("engine:health") // ["engine:health"]
130
+ * ```
131
+ */
132
+ export const expandPermission = (permission: string): string[] => {
133
+ const result = [permission];
134
+ const implied = PERMISSION_HIERARCHY[permission];
135
+ if (implied) {
136
+ result.push(...implied);
137
+ }
138
+ return result;
139
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Permission Types and Constants
3
+ *
4
+ * Defines the permission model for fine-grained access control in the uploadista engine.
5
+ * Permissions follow a hierarchical format: `resource:action` with support for wildcards.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Engine Permissions - Admin operations
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Engine permissions for administrative operations.
14
+ * These control access to health, readiness, metrics, and DLQ endpoints.
15
+ */
16
+ export const ENGINE_PERMISSIONS = {
17
+ /** Full admin access to all engine operations */
18
+ ALL: "engine:*",
19
+ /** Access health endpoint */
20
+ HEALTH: "engine:health",
21
+ /** Access readiness endpoint */
22
+ READINESS: "engine:readiness",
23
+ /** Access metrics endpoint */
24
+ METRICS: "engine:metrics",
25
+ /** Full DLQ access (implies read and write) */
26
+ DLQ: "engine:dlq",
27
+ /** Read DLQ entries */
28
+ DLQ_READ: "engine:dlq:read",
29
+ /** Retry/delete DLQ entries */
30
+ DLQ_WRITE: "engine:dlq:write",
31
+ } as const;
32
+
33
+ // ============================================================================
34
+ // Flow Permissions - Flow execution operations
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Flow permissions for flow execution operations.
39
+ */
40
+ export const FLOW_PERMISSIONS = {
41
+ /** Full access to all flow operations */
42
+ ALL: "flow:*",
43
+ /** Execute flows */
44
+ EXECUTE: "flow:execute",
45
+ /** Cancel running flows */
46
+ CANCEL: "flow:cancel",
47
+ /** Check flow status */
48
+ STATUS: "flow:status",
49
+ } as const;
50
+
51
+ // ============================================================================
52
+ // Upload Permissions - File upload operations
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Upload permissions for file upload operations.
57
+ */
58
+ export const UPLOAD_PERMISSIONS = {
59
+ /** Full access to all upload operations */
60
+ ALL: "upload:*",
61
+ /** Create uploads */
62
+ CREATE: "upload:create",
63
+ /** Read upload status */
64
+ READ: "upload:read",
65
+ /** Cancel uploads */
66
+ CANCEL: "upload:cancel",
67
+ } as const;
68
+
69
+ // ============================================================================
70
+ // Combined Permissions Object
71
+ // ============================================================================
72
+
73
+ /**
74
+ * All available permissions organized by category.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * import { PERMISSIONS } from "@uploadista/server";
79
+ *
80
+ * const adminPermissions = [PERMISSIONS.ENGINE.ALL];
81
+ * const userPermissions = [PERMISSIONS.FLOW.ALL, PERMISSIONS.UPLOAD.ALL];
82
+ * ```
83
+ */
84
+ export const PERMISSIONS = {
85
+ ENGINE: ENGINE_PERMISSIONS,
86
+ FLOW: FLOW_PERMISSIONS,
87
+ UPLOAD: UPLOAD_PERMISSIONS,
88
+ } as const;
89
+
90
+ // ============================================================================
91
+ // Permission Type Definitions
92
+ // ============================================================================
93
+
94
+ /** All engine permission strings */
95
+ export type EnginePermission =
96
+ (typeof ENGINE_PERMISSIONS)[keyof typeof ENGINE_PERMISSIONS];
97
+
98
+ /** All flow permission strings */
99
+ export type FlowPermission =
100
+ (typeof FLOW_PERMISSIONS)[keyof typeof FLOW_PERMISSIONS];
101
+
102
+ /** All upload permission strings */
103
+ export type UploadPermission =
104
+ (typeof UPLOAD_PERMISSIONS)[keyof typeof UPLOAD_PERMISSIONS];
105
+
106
+ /**
107
+ * Union type of all valid permission strings.
108
+ * Includes standard permissions and allows custom permissions via string.
109
+ */
110
+ export type Permission =
111
+ | EnginePermission
112
+ | FlowPermission
113
+ | UploadPermission
114
+ | (string & {}); // Allow custom permissions while maintaining autocomplete
115
+
116
+ /**
117
+ * Predefined permission sets for common use cases.
118
+ */
119
+ export const PERMISSION_SETS = {
120
+ /** Full admin access - all engine, flow, and upload permissions */
121
+ ADMIN: [ENGINE_PERMISSIONS.ALL] as const,
122
+
123
+ /** Organization owner - all flow and upload permissions */
124
+ ORGANIZATION_OWNER: [
125
+ FLOW_PERMISSIONS.ALL,
126
+ UPLOAD_PERMISSIONS.ALL,
127
+ ] as const,
128
+
129
+ /** Organization member - same as owner for now */
130
+ ORGANIZATION_MEMBER: [
131
+ FLOW_PERMISSIONS.ALL,
132
+ UPLOAD_PERMISSIONS.ALL,
133
+ ] as const,
134
+
135
+ /** API key - limited to execute flows and create uploads */
136
+ API_KEY: [
137
+ FLOW_PERMISSIONS.EXECUTE,
138
+ UPLOAD_PERMISSIONS.CREATE,
139
+ ] as const,
140
+ } as const;
141
+
142
+ /**
143
+ * Hierarchical permission relationships.
144
+ * When a parent permission is granted, all child permissions are implied.
145
+ */
146
+ export const PERMISSION_HIERARCHY: Record<string, readonly string[]> = {
147
+ [ENGINE_PERMISSIONS.DLQ]: [
148
+ ENGINE_PERMISSIONS.DLQ_READ,
149
+ ENGINE_PERMISSIONS.DLQ_WRITE,
150
+ ],
151
+ } as const;
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Flow, UploadServer } from "@uploadista/core";
18
- import type { ExtractLayerServices } from "@uploadista/core/flow/types";
18
+ import type { ExtractLayerServices } from "@uploadista/core/flow";
19
19
  import type { Effect, Layer } from "effect";
20
20
  import type z from "zod";
21
21
 
package/src/service.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import type { AuthContext } from "./types";
3
+ import {
4
+ hasPermission as matchHasPermission,
5
+ hasAnyPermission as matchHasAnyPermission,
6
+ } from "./permissions/matcher";
7
+ import { AuthorizationError, AuthenticationRequiredError } from "./permissions/errors";
3
8
 
4
9
  /**
5
10
  * Authentication Context Service
@@ -39,10 +44,68 @@ export class AuthContextService extends Context.Tag("AuthContextService")<
39
44
 
40
45
  /**
41
46
  * Check if the current client has a specific permission.
47
+ * Supports exact match, wildcard match, and hierarchical match.
42
48
  * Returns false if no authentication context or permission not found.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * // Exact match
53
+ * yield* authService.hasPermission("engine:health")
54
+ *
55
+ * // Wildcard: user with "engine:*" will match "engine:health"
56
+ * yield* authService.hasPermission("engine:health")
57
+ *
58
+ * // Hierarchical: user with "engine:dlq" will match "engine:dlq:read"
59
+ * yield* authService.hasPermission("engine:dlq:read")
60
+ * ```
43
61
  */
44
62
  readonly hasPermission: (permission: string) => Effect.Effect<boolean>;
45
63
 
64
+ /**
65
+ * Check if the current client has any of the specified permissions.
66
+ * Returns true if at least one permission is granted.
67
+ */
68
+ readonly hasAnyPermission: (
69
+ permissions: readonly string[],
70
+ ) => Effect.Effect<boolean>;
71
+
72
+ /**
73
+ * Require a specific permission, failing with AuthorizationError if not granted.
74
+ * Use this when you want to fail fast on missing permissions.
75
+ *
76
+ * @throws AuthorizationError if permission is not granted
77
+ * @throws AuthenticationRequiredError if no auth context
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const protectedHandler = Effect.gen(function* () {
82
+ * const authService = yield* AuthContextService;
83
+ * yield* authService.requirePermission("engine:metrics");
84
+ * // Only reaches here if permission is granted
85
+ * return yield* getMetrics();
86
+ * });
87
+ * ```
88
+ */
89
+ readonly requirePermission: (
90
+ permission: string,
91
+ ) => Effect.Effect<void, AuthorizationError | AuthenticationRequiredError>;
92
+
93
+ /**
94
+ * Require authentication, failing with AuthenticationRequiredError if not authenticated.
95
+ *
96
+ * @throws AuthenticationRequiredError if no auth context
97
+ */
98
+ readonly requireAuthentication: () => Effect.Effect<
99
+ AuthContext,
100
+ AuthenticationRequiredError
101
+ >;
102
+
103
+ /**
104
+ * Get all permissions granted to the current client.
105
+ * Returns empty array if no authentication context or no permissions.
106
+ */
107
+ readonly getPermissions: () => Effect.Effect<readonly string[]>;
108
+
46
109
  /**
47
110
  * Get the full authentication context if available.
48
111
  * Returns null if no authentication context is available.
@@ -60,14 +123,49 @@ export class AuthContextService extends Context.Tag("AuthContextService")<
60
123
  */
61
124
  export const AuthContextServiceLive = (
62
125
  authContext: AuthContext | null,
63
- ): Layer.Layer<AuthContextService> =>
64
- Layer.succeed(AuthContextService, {
126
+ ): Layer.Layer<AuthContextService> => {
127
+ const permissions = authContext?.permissions ?? [];
128
+
129
+ return Layer.succeed(AuthContextService, {
65
130
  getClientId: () => Effect.succeed(authContext?.clientId ?? null),
131
+
66
132
  getMetadata: () => Effect.succeed(authContext?.metadata ?? {}),
133
+
67
134
  hasPermission: (permission: string) =>
68
- Effect.succeed(authContext?.permissions?.includes(permission) ?? false),
135
+ Effect.succeed(matchHasPermission(permissions, permission)),
136
+
137
+ hasAnyPermission: (requiredPermissions: readonly string[]) =>
138
+ Effect.succeed(matchHasAnyPermission(permissions, requiredPermissions)),
139
+
140
+ requirePermission: (permission: string) =>
141
+ Effect.gen(function* () {
142
+ if (!authContext) {
143
+ yield* Effect.logDebug(
144
+ `[Auth] Permission check failed: authentication required for '${permission}'`,
145
+ );
146
+ return yield* Effect.fail(new AuthenticationRequiredError());
147
+ }
148
+ if (!matchHasPermission(permissions, permission)) {
149
+ yield* Effect.logDebug(
150
+ `[Auth] Permission denied: '${permission}' for client '${authContext.clientId}'`,
151
+ );
152
+ return yield* Effect.fail(new AuthorizationError(permission));
153
+ }
154
+ yield* Effect.logDebug(
155
+ `[Auth] Permission granted: '${permission}' for client '${authContext.clientId}'`,
156
+ );
157
+ }),
158
+
159
+ requireAuthentication: () =>
160
+ authContext
161
+ ? Effect.succeed(authContext)
162
+ : Effect.fail(new AuthenticationRequiredError()),
163
+
164
+ getPermissions: () => Effect.succeed(permissions),
165
+
69
166
  getAuthContext: () => Effect.succeed(authContext),
70
167
  });
168
+ };
71
169
 
72
170
  /**
73
171
  * No-auth implementation of AuthContextService.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Usage Hooks Module
3
+ *
4
+ * Exports all usage hook types and services.
5
+ */
6
+
7
+ export * from "./types";
8
+ export * from "./service";
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Usage Hook Service
3
+ *
4
+ * Effect service for executing usage tracking hooks with timeout handling.
5
+ */
6
+
7
+ import { Context, Effect, Layer } from "effect";
8
+ import type {
9
+ UsageHookConfig,
10
+ UsageHookResult,
11
+ UploadUsageContext,
12
+ FlowUsageContext,
13
+ } from "./types";
14
+ import { DEFAULT_USAGE_HOOK_TIMEOUT, continueResult } from "./types";
15
+
16
+ /**
17
+ * Usage Hook Service
18
+ *
19
+ * Provides methods to execute usage hooks during upload and flow processing.
20
+ * Handles timeout and error recovery gracefully.
21
+ */
22
+ export class UsageHookService extends Context.Tag("UsageHookService")<
23
+ UsageHookService,
24
+ {
25
+ /**
26
+ * Execute onUploadStart hook if configured.
27
+ * Returns continue result if no hook is configured or on error/timeout.
28
+ */
29
+ readonly onUploadStart: (
30
+ ctx: UploadUsageContext,
31
+ ) => Effect.Effect<UsageHookResult>;
32
+
33
+ /**
34
+ * Execute onUploadComplete hook if configured.
35
+ * Errors are logged but swallowed (fire-and-forget).
36
+ */
37
+ readonly onUploadComplete: (ctx: UploadUsageContext) => Effect.Effect<void>;
38
+
39
+ /**
40
+ * Execute onFlowStart hook if configured.
41
+ * Returns continue result if no hook is configured or on error/timeout.
42
+ */
43
+ readonly onFlowStart: (
44
+ ctx: FlowUsageContext,
45
+ ) => Effect.Effect<UsageHookResult>;
46
+
47
+ /**
48
+ * Execute onFlowComplete hook if configured.
49
+ * Errors are logged but swallowed (fire-and-forget).
50
+ */
51
+ readonly onFlowComplete: (ctx: FlowUsageContext) => Effect.Effect<void>;
52
+ }
53
+ >() {}
54
+
55
+ /**
56
+ * Creates a UsageHookService Layer from configuration.
57
+ *
58
+ * @param config - Usage hook configuration with optional hooks and timeout
59
+ * @returns Effect Layer providing UsageHookService
60
+ */
61
+ export const UsageHookServiceLive = (
62
+ config?: UsageHookConfig,
63
+ ): Layer.Layer<UsageHookService> => {
64
+ const hooks = config?.hooks;
65
+ const timeout = config?.timeout ?? DEFAULT_USAGE_HOOK_TIMEOUT;
66
+
67
+ return Layer.succeed(UsageHookService, {
68
+ onUploadStart: (ctx: UploadUsageContext) => {
69
+ if (!hooks?.onUploadStart) {
70
+ return Effect.succeed(continueResult());
71
+ }
72
+
73
+ return hooks
74
+ .onUploadStart(ctx)
75
+ .pipe(
76
+ // Add timeout - proceed on timeout (fail-open)
77
+ Effect.timeout(timeout),
78
+ Effect.map((result) => result ?? continueResult()),
79
+ // On any error, log and continue (fail-open for availability)
80
+ Effect.catchAll((error) =>
81
+ Effect.gen(function* () {
82
+ yield* Effect.logWarning(
83
+ `onUploadStart hook failed: ${error}. Proceeding with upload.`,
84
+ );
85
+ return continueResult();
86
+ }),
87
+ ),
88
+ );
89
+ },
90
+
91
+ onUploadComplete: (ctx: UploadUsageContext) => {
92
+ if (!hooks?.onUploadComplete) {
93
+ return Effect.void;
94
+ }
95
+
96
+ return hooks
97
+ .onUploadComplete(ctx)
98
+ .pipe(
99
+ // Add timeout
100
+ Effect.timeout(timeout),
101
+ Effect.asVoid,
102
+ // On any error, just log (fire-and-forget)
103
+ Effect.catchAll((error) =>
104
+ Effect.logWarning(
105
+ `onUploadComplete hook failed: ${error}. Upload already completed.`,
106
+ ),
107
+ ),
108
+ );
109
+ },
110
+
111
+ onFlowStart: (ctx: FlowUsageContext) => {
112
+ if (!hooks?.onFlowStart) {
113
+ return Effect.succeed(continueResult());
114
+ }
115
+
116
+ return hooks
117
+ .onFlowStart(ctx)
118
+ .pipe(
119
+ // Add timeout - proceed on timeout (fail-open)
120
+ Effect.timeout(timeout),
121
+ Effect.map((result) => result ?? continueResult()),
122
+ // On any error, log and continue (fail-open for availability)
123
+ Effect.catchAll((error) =>
124
+ Effect.gen(function* () {
125
+ yield* Effect.logWarning(
126
+ `onFlowStart hook failed: ${error}. Proceeding with flow.`,
127
+ );
128
+ return continueResult();
129
+ }),
130
+ ),
131
+ );
132
+ },
133
+
134
+ onFlowComplete: (ctx: FlowUsageContext) => {
135
+ if (!hooks?.onFlowComplete) {
136
+ return Effect.void;
137
+ }
138
+
139
+ return hooks
140
+ .onFlowComplete(ctx)
141
+ .pipe(
142
+ // Add timeout
143
+ Effect.timeout(timeout),
144
+ Effect.asVoid,
145
+ // On any error, just log (fire-and-forget)
146
+ Effect.catchAll((error) =>
147
+ Effect.logWarning(
148
+ `onFlowComplete hook failed: ${error}. Flow already completed.`,
149
+ ),
150
+ ),
151
+ );
152
+ },
153
+ });
154
+ };
155
+
156
+ /**
157
+ * No-op implementation of UsageHookService.
158
+ * All hooks are no-ops that return continue/void.
159
+ * Used when no usage hooks are configured (default backward compatibility).
160
+ */
161
+ export const NoUsageHookServiceLive: Layer.Layer<UsageHookService> =
162
+ UsageHookServiceLive();