@apso/cli 0.4.2 → 0.6.0

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.
Files changed (71) hide show
  1. package/README.md +796 -6
  2. package/dist/commands/server/new.js +1 -1
  3. package/dist/commands/server/scaffold.js +20 -9
  4. package/dist/lib/apsorc-parser.d.ts +3 -0
  5. package/dist/lib/apsorc-parser.js +11 -6
  6. package/dist/lib/controller.js +3 -1
  7. package/dist/lib/dto.js +9 -9
  8. package/dist/lib/entity.js +6 -5
  9. package/dist/lib/guards.d.ts +47 -0
  10. package/dist/lib/guards.js +176 -0
  11. package/dist/lib/index.d.ts +1 -0
  12. package/dist/lib/index.js +5 -1
  13. package/dist/lib/module.js +3 -1
  14. package/dist/lib/templates/guards/auth.guard.eta +228 -0
  15. package/dist/lib/templates/guards/guards.module.eta +114 -0
  16. package/dist/lib/templates/guards/index.eta +22 -0
  17. package/dist/lib/templates/guards/scope.guard.eta +327 -0
  18. package/dist/lib/types/auth.d.ts +203 -0
  19. package/dist/lib/types/auth.js +60 -0
  20. package/dist/lib/types/entity.d.ts +36 -1
  21. package/dist/lib/types/index.d.ts +2 -1
  22. package/dist/lib/types/index.js +8 -0
  23. package/dist/lib/types/relationship.d.ts +1 -1
  24. package/dist/lib/utils/casing.js +5 -5
  25. package/dist/lib/utils/field.js +2 -1
  26. package/dist/lib/utils/file-system.js +1 -1
  27. package/dist/lib/utils/relationships/parse-v1.js +2 -1
  28. package/dist/lib/utils/relationships/parse.js +117 -32
  29. package/dist/lib/utils/relationships/parse.spec.js +168 -141
  30. package/dist/test-nested-relationships.js +10 -10
  31. package/dist/tests/templates/entities/types/array-template.spec.js +45 -45
  32. package/dist/tests/templates/entities/types/bigint-template.spec.js +43 -43
  33. package/dist/tests/templates/entities/types/boolean-template.spec.js +44 -44
  34. package/dist/tests/templates/entities/types/bytea-template.spec.js +35 -35
  35. package/dist/tests/templates/entities/types/char-template.spec.js +35 -35
  36. package/dist/tests/templates/entities/types/date-template.spec.js +29 -29
  37. package/dist/tests/templates/entities/types/decimal-field-validation.spec.js +46 -46
  38. package/dist/tests/templates/entities/types/decimal-template.spec.js +46 -46
  39. package/dist/tests/templates/entities/types/double-template.spec.js +37 -34
  40. package/dist/tests/templates/entities/types/enum-template.spec.js +42 -42
  41. package/dist/tests/templates/entities/types/float-template.spec.js +37 -34
  42. package/dist/tests/templates/entities/types/geometry-template.spec.js +31 -31
  43. package/dist/tests/templates/entities/types/inet-template.spec.js +36 -36
  44. package/dist/tests/templates/entities/types/int4range-template.spec.js +45 -33
  45. package/dist/tests/templates/entities/types/integer-template.spec.js +40 -40
  46. package/dist/tests/templates/entities/types/interval-template.spec.js +35 -35
  47. package/dist/tests/templates/entities/types/json-plain-template.spec.js +27 -27
  48. package/dist/tests/templates/entities/types/json-template.spec.js +19 -19
  49. package/dist/tests/templates/entities/types/jsonb-template.spec.js +19 -19
  50. package/dist/tests/templates/entities/types/money-template.spec.js +35 -35
  51. package/dist/tests/templates/entities/types/numeric-template.spec.js +44 -44
  52. package/dist/tests/templates/entities/types/point-template.spec.js +37 -31
  53. package/dist/tests/templates/entities/types/polygon-template.spec.js +43 -31
  54. package/dist/tests/templates/entities/types/real-template.spec.js +34 -34
  55. package/dist/tests/templates/entities/types/serial-template.spec.js +62 -62
  56. package/dist/tests/templates/entities/types/smallint-template.spec.js +43 -43
  57. package/dist/tests/templates/entities/types/string-template.spec.js +59 -59
  58. package/dist/tests/templates/entities/types/text-template.spec.js +37 -37
  59. package/dist/tests/templates/entities/types/time-template.spec.js +35 -35
  60. package/dist/tests/templates/entities/types/timestamp-template.spec.js +39 -39
  61. package/dist/tests/templates/entities/types/timetz-template.spec.js +35 -35
  62. package/dist/tests/templates/entities/types/tsvector-template.spec.js +28 -28
  63. package/dist/tests/templates/entities/types/uuid-template.spec.js +43 -43
  64. package/dist/tests/templates/entities/types/varchar-template.spec.js +59 -56
  65. package/dist/tests/templates/entities/types/xml-template.spec.js +28 -28
  66. package/dist/tests/templates/entities/utils/template-test-utils.d.ts +1 -1
  67. package/dist/tests/templates/entities/utils/template-test-utils.js +3 -3
  68. package/dist/tests/templates/entities/uuid-entity-template.spec.js +138 -138
  69. package/dist/tests/utils/relationship-deduplication.spec.js +11 -11
  70. package/oclif.manifest.json +1 -1
  71. package/package.json +1 -1
@@ -0,0 +1,228 @@
1
+ /**
2
+ * AuthGuard - Authentication guard for <%= it.authConfig.provider %> provider
3
+ *
4
+ * Auto-generated by Apso CLI from .apsorc auth configuration
5
+ * Generated at: <%= it.generatedAt %>
6
+ *
7
+ * This guard:
8
+ * - Validates session tokens from cookies/headers
9
+ * - Loads user and organization context
10
+ * - Attaches normalized AuthContext to requests
11
+ *
12
+ * DO NOT EDIT - Changes will be overwritten on next scaffold
13
+ */
14
+ import {
15
+ Injectable,
16
+ CanActivate,
17
+ ExecutionContext,
18
+ UnauthorizedException,
19
+ } from '@nestjs/common';
20
+ import { Reflector } from '@nestjs/core';
21
+ import { InjectRepository } from '@nestjs/typeorm';
22
+ import { Repository } from 'typeorm';
23
+
24
+ // Entity imports
25
+ import { <%= it.authConfig.sessionEntity %> } from '../autogen/<%= it.authConfig.sessionEntity %>/<%= it.authConfig.sessionEntity %>.entity';
26
+ import { <%= it.authConfig.userEntity %> } from '../autogen/<%= it.authConfig.userEntity %>/<%= it.authConfig.userEntity %>.entity';
27
+ <% if (it.authConfig.accountUserEntity) { %>
28
+ import { <%= it.authConfig.accountUserEntity %> } from '../autogen/<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
29
+ <% } %>
30
+
31
+ /**
32
+ * Metadata key for marking routes as public (no auth required)
33
+ */
34
+ export const IS_PUBLIC_KEY = 'isPublic';
35
+
36
+ /**
37
+ * Decorator to mark a route as public (skips authentication)
38
+ * Usage: @Public()
39
+ */
40
+ export const Public =
41
+ () => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
42
+ Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
43
+ };
44
+
45
+ /**
46
+ * Normalized auth context attached to authenticated requests.
47
+ * This is what scopeBy and RBAC consume.
48
+ */
49
+ export interface AuthContext {
50
+ userId?: string;
51
+ email?: string;
52
+ workspaceId?: string;
53
+ organizationId?: string;
54
+ roles: string[];
55
+ user?: <%= it.authConfig.userEntity %>;
56
+ session?: <%= it.authConfig.sessionEntity %>;
57
+ }
58
+
59
+ /**
60
+ * Extended request type with auth context
61
+ */
62
+ export interface AuthenticatedRequest extends Request {
63
+ auth: AuthContext;
64
+ user: <%= it.authConfig.userEntity %>;
65
+ session: <%= it.authConfig.sessionEntity %>;
66
+ // Legacy compatibility aliases
67
+ accountId?: string;
68
+ accountRole?: string;
69
+ organizationId?: string;
70
+ }
71
+
72
+ @Injectable()
73
+ export class AuthGuard implements CanActivate {
74
+ constructor(
75
+ private reflector: Reflector,
76
+ @InjectRepository(<%= it.authConfig.sessionEntity %>)
77
+ private sessionRepository: Repository<<%= it.authConfig.sessionEntity %>>,
78
+ @InjectRepository(<%= it.authConfig.userEntity %>)
79
+ private userRepository: Repository<<%= it.authConfig.userEntity %>>,
80
+ <% if (it.authConfig.accountUserEntity) { %>
81
+ @InjectRepository(<%= it.authConfig.accountUserEntity %>)
82
+ private accountUserRepository: Repository<<%= it.authConfig.accountUserEntity %>>,
83
+ <% } %>
84
+ ) {}
85
+
86
+ async canActivate(context: ExecutionContext): Promise<boolean> {
87
+ // Check if route is marked as public
88
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
89
+ context.getHandler(),
90
+ context.getClass(),
91
+ ]);
92
+
93
+ if (isPublic) {
94
+ return true;
95
+ }
96
+
97
+ const request = context.switchToHttp().getRequest();
98
+ const token = this.extractToken(request);
99
+
100
+ if (!token) {
101
+ throw new UnauthorizedException('No session token provided');
102
+ }
103
+
104
+ try {
105
+ // Find session by token
106
+ const sessionRecord = await this.sessionRepository.findOne({
107
+ where: { token },
108
+ });
109
+
110
+ if (!sessionRecord) {
111
+ throw new UnauthorizedException('Invalid session token');
112
+ }
113
+
114
+ // Check if session is expired
115
+ if (new Date(sessionRecord.expiresAt) < new Date()) {
116
+ throw new UnauthorizedException('Session expired');
117
+ }
118
+
119
+ // Get user from session
120
+ const user = await this.userRepository.findOne({
121
+ where: { id: sessionRecord.userId },
122
+ });
123
+
124
+ if (!user) {
125
+ throw new UnauthorizedException('User not found');
126
+ }
127
+
128
+ <% if (it.authConfig.accountUserEntity) { %>
129
+ // Get user's organization through <%= it.authConfig.accountUserEntity %> junction
130
+ const accountUser = await this.accountUserRepository.findOne({
131
+ where: { userId: user.id },
132
+ });
133
+
134
+ if (!accountUser) {
135
+ throw new UnauthorizedException('User not associated with any organization');
136
+ }
137
+
138
+ const organizationId = accountUser.<%= it.authConfig.organizationField %>;
139
+ const role = accountUser.<%= it.authConfig.roleField %> || 'member';
140
+ <% } else { %>
141
+ // No account user entity configured - using user-level organization
142
+ const organizationId = (user as any).<%= it.authConfig.organizationField %> || undefined;
143
+ const role = (user as any).<%= it.authConfig.roleField %> || 'member';
144
+ <% } %>
145
+
146
+ // Build normalized AuthContext
147
+ const authContext: AuthContext = {
148
+ userId: user.id,
149
+ email: user.email,
150
+ workspaceId: organizationId,
151
+ organizationId: organizationId,
152
+ roles: [role],
153
+ user: user,
154
+ session: sessionRecord,
155
+ };
156
+
157
+ // Attach to request
158
+ (request as any).auth = authContext;
159
+ (request as any).user = user;
160
+ (request as any).session = sessionRecord;
161
+
162
+ // Legacy compatibility - attach directly to request root
163
+ (request as any).accountId = organizationId;
164
+ (request as any).accountRole = role;
165
+ (request as any).organizationId = organizationId;
166
+
167
+ return true;
168
+ } catch (error) {
169
+ if (error instanceof UnauthorizedException) {
170
+ throw error;
171
+ }
172
+ console.error('Auth guard error:', error);
173
+ throw new UnauthorizedException('Authentication failed');
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Extract session token from request
179
+ * Checks: Authorization header (Bearer), cookies, X-Session-Token header
180
+ */
181
+ private extractToken(request: any): string | undefined {
182
+ // Check Authorization header first (Bearer token)
183
+ const authHeader = request.headers.authorization;
184
+ if (authHeader && authHeader.startsWith('Bearer ')) {
185
+ return authHeader.substring(7);
186
+ }
187
+
188
+ // Check for session token in cookies
189
+ const cookies = request.cookies || {};
190
+ <% if (it.authConfig.cookiePrefix) { %>
191
+ const cookieName = '<%= it.authConfig.cookiePrefix %>.session_token';
192
+ const sessionToken =
193
+ cookies[cookieName] ||
194
+ cookies['better-auth.session_token'] ||
195
+ cookies['session_token'];
196
+ <% } else { %>
197
+ const sessionToken =
198
+ cookies['better-auth.session_token'] ||
199
+ cookies['session_token'];
200
+ <% } %>
201
+
202
+ if (sessionToken) {
203
+ return sessionToken;
204
+ }
205
+
206
+ // Check X-Session-Token header
207
+ return request.headers['x-session-token'];
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Helper function to get AuthContext from request
213
+ * Use this in controllers/services to access the authenticated context
214
+ */
215
+ export function getAuthContext(request: any): AuthContext | undefined {
216
+ return request?.auth;
217
+ }
218
+
219
+ /**
220
+ * Helper function to require AuthContext (throws if not present)
221
+ */
222
+ export function requireAuthContext(request: any): AuthContext {
223
+ const ctx = getAuthContext(request);
224
+ if (!ctx) {
225
+ throw new UnauthorizedException('Authentication required');
226
+ }
227
+ return ctx;
228
+ }
@@ -0,0 +1,114 @@
1
+ <%~ includeFile('../header.eta') %>
2
+
3
+ import { Module, Global } from '@nestjs/common';
4
+ import { TypeOrmModule } from '@nestjs/typeorm';
5
+ import { APP_GUARD } from '@nestjs/core';
6
+
7
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
8
+ import { ScopeGuard } from './scope.guard';
9
+ <% } %>
10
+ <% if (it.authConfig) { %>
11
+ import { AuthGuard } from './auth.guard';
12
+ <% } %>
13
+
14
+ <% /* Import all entities for ScopeGuard */ %>
15
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
16
+ // Entities for ScopeGuard
17
+ <% it.scopedEntities.forEach((entity) => { %>
18
+ import { <%= entity.name %> } from '../autogen/<%= entity.name %>/<%= entity.name %>.entity';
19
+ <% }) %>
20
+ <% } %>
21
+
22
+ <% /* Import entities for AuthGuard */ %>
23
+ <% if (it.authConfig) { %>
24
+ // Entities for AuthGuard
25
+ import { <%= it.authConfig.sessionEntity %> } from '../autogen/<%= it.authConfig.sessionEntity %>/<%= it.authConfig.sessionEntity %>.entity';
26
+ import { <%= it.authConfig.userEntity %> } from '../autogen/<%= it.authConfig.userEntity %>/<%= it.authConfig.userEntity %>.entity';
27
+ <% if (it.authConfig.accountUserEntity) { %>
28
+ import { <%= it.authConfig.accountUserEntity %> } from '../autogen/<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
29
+ <% } %>
30
+ <% } %>
31
+
32
+ /**
33
+ * GuardsModule provides authentication and scope-based data isolation guards.
34
+ *
35
+ * Auto-generated from .apsorc configuration.
36
+ * Generated at: <%= it.generatedAt %>
37
+ *
38
+ * Usage:
39
+ * 1. Import GuardsModule in your AppModule
40
+ * 2. Guards are NOT enabled globally by default (for backward compatibility)
41
+ * 3. To enable guards globally, uncomment the APP_GUARD providers below
42
+ * 4. Or apply guards selectively using @UseGuards(AuthGuard, ScopeGuard) on controllers
43
+ *
44
+ <% if (it.authConfig) { %>
45
+ * AuthGuard:
46
+ * - Validates session tokens from cookies/headers
47
+ * - Loads user and organization context
48
+ * - Attaches normalized AuthContext to request.auth
49
+ * - Provider: <%= it.authConfig.provider %>
50
+ *
51
+ <% } %>
52
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
53
+ * ScopeGuard:
54
+ * - Enforces data isolation based on scopeBy config
55
+ * - POST requests auto-inject scope fields from request.auth
56
+ * - GET requests auto-filter by scope fields
57
+ * - GET/PUT/PATCH/DELETE by ID verify resource ownership
58
+ *
59
+ <% } %>
60
+ * Decorators:
61
+ * - @Public() - Skip all guards for a route
62
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
63
+ * - @SkipScopeCheck() - Skip only scope checking
64
+ <% } %>
65
+ */
66
+ @Global()
67
+ @Module({
68
+ imports: [
69
+ TypeOrmModule.forFeature([
70
+ <% /* Collect all unique entities */ %>
71
+ <% const allEntities = new Set(); %>
72
+ <% if (it.scopedEntities) { it.scopedEntities.forEach(e => allEntities.add(e.name)); } %>
73
+ <% if (it.authConfig) { %>
74
+ <% allEntities.add(it.authConfig.sessionEntity); %>
75
+ <% allEntities.add(it.authConfig.userEntity); %>
76
+ <% if (it.authConfig.accountUserEntity) { allEntities.add(it.authConfig.accountUserEntity); } %>
77
+ <% } %>
78
+ <% const entityArray = Array.from(allEntities); %>
79
+ <% entityArray.forEach((entity, index) => { %>
80
+ <%= entity %><%= index < entityArray.length - 1 ? ',' : '' %>
81
+ <% }) %>
82
+ ]),
83
+ ],
84
+ providers: [
85
+ <% if (it.authConfig) { %>
86
+ AuthGuard,
87
+ <% } %>
88
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
89
+ ScopeGuard,
90
+ <% } %>
91
+ // UNCOMMENT BELOW TO ENABLE GLOBAL GUARDS
92
+ <% if (it.authConfig) { %>
93
+ // {
94
+ // provide: APP_GUARD,
95
+ // useClass: AuthGuard,
96
+ // },
97
+ <% } %>
98
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
99
+ // {
100
+ // provide: APP_GUARD,
101
+ // useClass: ScopeGuard,
102
+ // },
103
+ <% } %>
104
+ ],
105
+ exports: [
106
+ <% if (it.authConfig) { %>
107
+ AuthGuard,
108
+ <% } %>
109
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
110
+ ScopeGuard,
111
+ <% } %>
112
+ ],
113
+ })
114
+ export class GuardsModule {}
@@ -0,0 +1,22 @@
1
+ <%~ includeFile('../header.eta') %>
2
+
3
+ <% if (it.authConfig) { %>
4
+ export * from './auth.guard';
5
+ <% } %>
6
+ <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
7
+ <% if (it.authConfig) { %>
8
+ // When auth is configured, only export scope-specific items from scope.guard
9
+ // to avoid duplicate exports (Public, IS_PUBLIC_KEY come from auth.guard)
10
+ export {
11
+ ScopeGuard,
12
+ SKIP_SCOPE_CHECK,
13
+ SkipScopeCheck,
14
+ ScopeConfig,
15
+ ScopeField,
16
+ ENTITY_SCOPES,
17
+ } from './scope.guard';
18
+ <% } else { %>
19
+ export * from './scope.guard';
20
+ <% } %>
21
+ <% } %>
22
+ export * from './guards.module';
@@ -0,0 +1,327 @@
1
+ <%~ includeFile('../header.eta') %>
2
+
3
+ import {
4
+ Injectable,
5
+ CanActivate,
6
+ ExecutionContext,
7
+ ForbiddenException,
8
+ } from '@nestjs/common';
9
+ import { Reflector } from '@nestjs/core';
10
+ import { InjectRepository } from '@nestjs/typeorm';
11
+ import { Repository } from 'typeorm';
12
+
13
+ <% /* Import all entities that have scoped relationships */ %>
14
+ <% it.scopedEntities.forEach((entity) => { %>
15
+ import { <%= entity.name %> } from '../autogen/<%= entity.name %>/<%= entity.name %>.entity';
16
+ <% }) %>
17
+
18
+ /**
19
+ * Decorator to skip scope check for certain routes
20
+ */
21
+ export const SKIP_SCOPE_CHECK = 'skipScopeCheck';
22
+ export const SkipScopeCheck = () =>
23
+ (target: any, key?: string, descriptor?: PropertyDescriptor) => {
24
+ Reflect.defineMetadata(
25
+ SKIP_SCOPE_CHECK,
26
+ true,
27
+ descriptor?.value || target,
28
+ );
29
+ };
30
+
31
+ <% if (it.authConfig) { %>
32
+ /**
33
+ * Import Public decorator from auth.guard to avoid duplicate exports
34
+ */
35
+ import { IS_PUBLIC_KEY, Public } from './auth.guard';
36
+ export { IS_PUBLIC_KEY, Public };
37
+ <% } else { %>
38
+ /**
39
+ * Decorator to mark routes as public (no auth required)
40
+ */
41
+ export const IS_PUBLIC_KEY = 'isPublic';
42
+ export const Public = () =>
43
+ (target: any, key?: string, descriptor?: PropertyDescriptor) => {
44
+ Reflect.defineMetadata(
45
+ IS_PUBLIC_KEY,
46
+ true,
47
+ descriptor?.value || target,
48
+ );
49
+ };
50
+ <% } %>
51
+
52
+ /**
53
+ * Scope configuration for each entity
54
+ * Generated from .apsorc scopeBy definitions
55
+ */
56
+ export interface ScopeConfig {
57
+ /** The scope fields or paths (e.g., 'workspaceId' or 'task.workspaceId') */
58
+ scopes: ScopeField[];
59
+ /** Whether to inject scope on create */
60
+ injectOnCreate: boolean;
61
+ /** Which operations to enforce scope on */
62
+ enforceOn: string[];
63
+ /** Roles that can bypass scope enforcement */
64
+ bypassRoles: string[];
65
+ }
66
+
67
+ export interface ScopeField {
68
+ /** The field name on the entity (e.g., 'workspaceId') or first segment of path */
69
+ field: string;
70
+ /** The context key to get the value from (defaults to field name) */
71
+ contextKey: string;
72
+ /** Whether this is a direct field (true) or nested path (false) */
73
+ direct: boolean;
74
+ /** For nested paths, the full path (e.g., 'task.workspaceId') */
75
+ path?: string;
76
+ }
77
+
78
+ /**
79
+ * Entity scope configuration map
80
+ * Auto-generated from .apsorc
81
+ */
82
+ export const ENTITY_SCOPES: Record<string, ScopeConfig> = {
83
+ <% it.scopedEntities.forEach((entity, index) => { %>
84
+ '<%= entity.routeName %>': {
85
+ scopes: [
86
+ <% entity.scopes.forEach((scope, scopeIndex) => { %>
87
+ {
88
+ field: '<%= scope.field %>',
89
+ contextKey: '<%= scope.contextKey %>',
90
+ direct: <%= scope.direct %>,
91
+ <% if (scope.path) { %>
92
+ path: '<%= scope.path %>',
93
+ <% } %>
94
+ }<%= scopeIndex < entity.scopes.length - 1 ? ',' : '' %>
95
+ <% }) %>
96
+ ],
97
+ injectOnCreate: <%= entity.injectOnCreate %>,
98
+ enforceOn: [<%~ entity.enforceOn.map(op => `'${op}'`).join(', ') %>],
99
+ bypassRoles: [<%~ entity.bypassRoles.map(role => `'${role}'`).join(', ') %>],
100
+ }<%= index < it.scopedEntities.length - 1 ? ',' : '' %>
101
+ <% }) %>
102
+ };
103
+
104
+ @Injectable()
105
+ export class ScopeGuard implements CanActivate {
106
+ constructor(
107
+ private reflector: Reflector,
108
+ <% it.scopedEntities.forEach((entity) => { %>
109
+ @InjectRepository(<%= entity.name %>)
110
+ private <%= entity.repoName %>: Repository<<%= entity.name %>>,
111
+ <% }) %>
112
+ ) {}
113
+
114
+ async canActivate(context: ExecutionContext): Promise<boolean> {
115
+ // Check if route should skip scope check
116
+ const skipCheck = this.reflector.getAllAndOverride<boolean>(
117
+ SKIP_SCOPE_CHECK,
118
+ [context.getHandler(), context.getClass()],
119
+ );
120
+
121
+ if (skipCheck) {
122
+ return true;
123
+ }
124
+
125
+ // Check if route is public
126
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
127
+ context.getHandler(),
128
+ context.getClass(),
129
+ ]);
130
+
131
+ if (isPublic) {
132
+ return true;
133
+ }
134
+
135
+ const request = context.switchToHttp().getRequest();
136
+
137
+ // Get the resource path from the request
138
+ const path = request.route?.path || request.path;
139
+ const method = request.method;
140
+
141
+ // Extract entity type from path (e.g., /Projects/:id -> projects)
142
+ const entityMatch = path.match(/^\/([^\/]+)/);
143
+ if (!entityMatch) {
144
+ return true;
145
+ }
146
+
147
+ const entityType = entityMatch[1].toLowerCase();
148
+
149
+ // Check if this is a scoped entity
150
+ const scopeConfig = ENTITY_SCOPES[entityType];
151
+ if (!scopeConfig) {
152
+ // Not a scoped entity, allow through
153
+ return true;
154
+ }
155
+
156
+ // Check if user has bypass role
157
+ const userRoles: string[] = request.user?.roles || request.roles || [];
158
+ if (scopeConfig.bypassRoles.some(role => userRoles.includes(role))) {
159
+ return true;
160
+ }
161
+
162
+ // Map HTTP method to operation
163
+ const operation = this.mapMethodToOperation(method, request.params?.id);
164
+
165
+ // Check if this operation should be enforced
166
+ if (!scopeConfig.enforceOn.includes(operation)) {
167
+ return true;
168
+ }
169
+
170
+ // For GET requests with an ID, verify the resource belongs to this scope
171
+ if ((operation === 'get' || operation === 'update' || operation === 'delete') && request.params?.id) {
172
+ const hasAccess = await this.verifyResourceAccess(
173
+ entityType,
174
+ request.params.id,
175
+ request,
176
+ scopeConfig,
177
+ );
178
+ if (!hasAccess) {
179
+ throw new ForbiddenException('You do not have access to this resource');
180
+ }
181
+ }
182
+
183
+ // For POST requests, inject scope values if configured
184
+ if (operation === 'create' && scopeConfig.injectOnCreate) {
185
+ this.injectScopeValues(request, scopeConfig);
186
+ }
187
+
188
+ // For GET (list) requests, add scope filter
189
+ if (operation === 'find') {
190
+ this.addScopeFilter(request, scopeConfig);
191
+ }
192
+
193
+ return true;
194
+ }
195
+
196
+ private mapMethodToOperation(method: string, hasId: boolean): string {
197
+ switch (method) {
198
+ case 'GET':
199
+ return hasId ? 'get' : 'find';
200
+ case 'POST':
201
+ return 'create';
202
+ case 'PUT':
203
+ case 'PATCH':
204
+ return 'update';
205
+ case 'DELETE':
206
+ return 'delete';
207
+ default:
208
+ return 'find';
209
+ }
210
+ }
211
+
212
+ private async verifyResourceAccess(
213
+ entityType: string,
214
+ resourceId: string,
215
+ request: any,
216
+ scopeConfig: ScopeConfig,
217
+ ): Promise<boolean> {
218
+ try {
219
+ const repo = this.getRepository(entityType);
220
+ if (!repo) {
221
+ return true; // Unknown entity, allow through
222
+ }
223
+
224
+ // Build relations array for nested scopes
225
+ const relations: string[] = [];
226
+ for (const scope of scopeConfig.scopes) {
227
+ if (!scope.direct && scope.path) {
228
+ // Extract relation name from path (e.g., 'task.workspaceId' -> 'task')
229
+ const relationName = scope.path.split('.')[0];
230
+ if (!relations.includes(relationName)) {
231
+ relations.push(relationName);
232
+ }
233
+ }
234
+ }
235
+
236
+ const entity = await repo.findOne({
237
+ where: { id: resourceId },
238
+ relations,
239
+ });
240
+
241
+ if (!entity) {
242
+ return false;
243
+ }
244
+
245
+ // Verify all scope conditions
246
+ for (const scope of scopeConfig.scopes) {
247
+ const expectedValue = this.getScopeValue(request, scope.contextKey);
248
+ if (!expectedValue) {
249
+ continue; // No scope value in context, skip this check
250
+ }
251
+
252
+ let actualValue: any;
253
+ if (scope.direct) {
254
+ actualValue = (entity as any)[scope.field];
255
+ } else if (scope.path) {
256
+ // Navigate the path (e.g., 'task.workspaceId')
257
+ actualValue = this.getNestedValue(entity, scope.path);
258
+ }
259
+
260
+ if (actualValue !== expectedValue) {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ return true;
266
+ } catch (error) {
267
+ console.error('Scope verification error:', error);
268
+ return false;
269
+ }
270
+ }
271
+
272
+ private injectScopeValues(request: any, scopeConfig: ScopeConfig): void {
273
+ if (!request.body || typeof request.body !== 'object') {
274
+ return;
275
+ }
276
+
277
+ for (const scope of scopeConfig.scopes) {
278
+ if (scope.direct) {
279
+ const scopeValue = this.getScopeValue(request, scope.contextKey);
280
+ if (scopeValue && !request.body[scope.field]) {
281
+ request.body[scope.field] = scopeValue;
282
+ }
283
+ }
284
+ }
285
+ }
286
+
287
+ private addScopeFilter(request: any, scopeConfig: ScopeConfig): void {
288
+ if (!request.query) {
289
+ request.query = {};
290
+ }
291
+
292
+ for (const scope of scopeConfig.scopes) {
293
+ if (scope.direct) {
294
+ const scopeValue = this.getScopeValue(request, scope.contextKey);
295
+ if (scopeValue) {
296
+ // Add filter for @nestjsx/crud
297
+ request.query[`filter.${scope.field}`] = `$eq:${scopeValue}`;
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ private getScopeValue(request: any, contextKey: string): string | undefined {
304
+ // Try multiple locations for the scope value
305
+ return (
306
+ request[contextKey] ||
307
+ request.user?.[contextKey] ||
308
+ request.scope?.[contextKey] ||
309
+ request.context?.[contextKey]
310
+ );
311
+ }
312
+
313
+ private getNestedValue(obj: any, path: string): any {
314
+ return path.split('.').reduce((current, key) => current?.[key], obj);
315
+ }
316
+
317
+ private getRepository(entityType: string): Repository<any> | null {
318
+ switch (entityType) {
319
+ <% it.scopedEntities.forEach((entity) => { %>
320
+ case '<%= entity.routeName %>':
321
+ return this.<%= entity.repoName %>;
322
+ <% }) %>
323
+ default:
324
+ return null;
325
+ }
326
+ }
327
+ }