@apso/cli 0.43.4 → 0.45.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 (65) hide show
  1. package/README.md +7 -11
  2. package/dist/commands/deploy.js +1 -1
  3. package/dist/commands/generate.js +21 -1
  4. package/dist/commands/mcp/serve.js +30 -9
  5. package/dist/commands/migrate/generate.d.ts +3 -1
  6. package/dist/commands/migrate/generate.js +42 -8
  7. package/dist/commands/migrate/plan.d.ts +12 -0
  8. package/dist/commands/migrate/plan.js +88 -0
  9. package/dist/commands/migrate/sandbox.d.ts +11 -0
  10. package/dist/commands/migrate/sandbox.js +132 -0
  11. package/dist/commands/migrate/verify.d.ts +5 -2
  12. package/dist/commands/migrate/verify.js +44 -9
  13. package/dist/commands/migrate.d.ts +16 -3
  14. package/dist/commands/migrate.js +171 -96
  15. package/dist/lib/ddl/catalog.d.ts +21 -0
  16. package/dist/lib/ddl/catalog.js +58 -0
  17. package/dist/lib/ddl/change.d.ts +19 -0
  18. package/dist/lib/ddl/change.js +393 -0
  19. package/dist/lib/ddl/index.d.ts +80 -0
  20. package/dist/lib/ddl/index.js +309 -0
  21. package/dist/lib/ddl/migration-file.d.ts +42 -0
  22. package/dist/lib/ddl/migration-file.js +222 -0
  23. package/dist/lib/generators/base.d.ts +1 -0
  24. package/dist/lib/generators/go.d.ts +2 -1
  25. package/dist/lib/generators/go.js +37 -6
  26. package/dist/lib/generators/python.d.ts +4 -2
  27. package/dist/lib/generators/python.js +31 -8
  28. package/dist/lib/generators/typescript.d.ts +2 -1
  29. package/dist/lib/generators/typescript.js +43 -6
  30. package/dist/lib/guards.d.ts +11 -1
  31. package/dist/lib/guards.js +20 -1
  32. package/dist/lib/plan/baseline.d.ts +22 -0
  33. package/dist/lib/plan/baseline.js +100 -0
  34. package/dist/lib/plan/index.d.ts +52 -0
  35. package/dist/lib/plan/index.js +272 -0
  36. package/dist/lib/plan/prompts.d.ts +18 -0
  37. package/dist/lib/plan/prompts.js +77 -0
  38. package/dist/lib/templates/go/index-module.eta +6 -0
  39. package/dist/lib/templates/go/middleware/auth.eta +299 -59
  40. package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
  41. package/dist/lib/templates/guards/auth.guard.eta +6 -0
  42. package/dist/lib/templates/guards/guards.module.eta +11 -19
  43. package/dist/lib/templates/guards/index.eta +2 -2
  44. package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
  45. package/dist/lib/templates/guards/scope.guard.eta +20 -2
  46. package/dist/lib/templates/python/auth.eta +217 -0
  47. package/dist/lib/templates/python/index-module.eta +11 -0
  48. package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
  49. package/dist/lib/templates/python/schemas/schema.eta +22 -1
  50. package/dist/lib/templates/rest/controller-rest.eta +16 -1
  51. package/dist/lib/templates/rest/index-module-rest.eta +6 -0
  52. package/dist/lib/types/auth.d.ts +41 -7
  53. package/dist/lib/types/auth.js +61 -2
  54. package/dist/lib/types/generator.d.ts +6 -0
  55. package/dist/lib/types/index.d.ts +1 -1
  56. package/dist/lib/types/index.js +3 -1
  57. package/dist/lib/utils/crud-deps.d.ts +13 -0
  58. package/dist/lib/utils/crud-deps.js +37 -1
  59. package/dist/lib/verify/index.d.ts +27 -9
  60. package/dist/lib/verify/index.js +249 -21
  61. package/dist/lib/verify/review.d.ts +14 -0
  62. package/dist/lib/verify/review.js +98 -0
  63. package/npm-shrinkwrap.json +2 -2
  64. package/oclif.manifest.json +176 -23
  65. package/package.json +1 -1
@@ -1,100 +1,340 @@
1
- <%~ includeFile('../../header.eta', it) %>
1
+ <%~ includeFile('../header.eta', it) %>
2
2
 
3
3
  package middleware
4
4
 
5
+ // AuthMiddleware authenticates every route registered on the /api group.
6
+ // Routes registered outside that group (such as /health) stay public.
7
+ <% if (it.jwt) { %>
8
+ //
9
+ // It verifies <%= it.jwt.provider %> JWTs from the Authorization: Bearer header against the
10
+ // issuer's published keys (JWKS), checks issuer, expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm,
11
+ // and maps the token's claims into an AuthContext.
12
+ // AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc values.
13
+ <% } else { %>
14
+ //
15
+ // It reads an API key from the <%= it.apiKey.apiKeyHeader %> header and looks up the
16
+ // <%= it.apiKey.apiKeyEntity %> row whose <%= it.apiKey.keyField %> is the key's hex SHA-256.
17
+ <% } %>
18
+
5
19
  import (
20
+ <% if (it.jwt) { %>
21
+ "context"
22
+ "encoding/json"
23
+ <% } else { %>
24
+ "crypto/sha256"
25
+ "encoding/hex"
26
+ "fmt"
27
+ <% } %>
6
28
  "net/http"
29
+ <% if (it.jwt) { %>
30
+ "os"
31
+ "strconv"
7
32
  "strings"
33
+ "sync"
34
+ <% } else if (it.apiKey.hasExpiresAt) { %>
35
+ "time"
36
+ <% } %>
8
37
 
38
+ <% if (it.jwt) { %>
39
+ "github.com/MicahParks/keyfunc/v3"
40
+ <% } %>
9
41
  "github.com/gin-gonic/gin"
42
+ <% if (it.jwt) { %>
43
+ "github.com/golang-jwt/jwt/v5"
44
+ <% } %>
45
+ "gorm.io/gorm"
46
+ <% if (it.apiKey) { %>
47
+
48
+ "app/autogen/models"
49
+ <% } %>
10
50
  )
11
51
 
12
- // AuthMiddleware provides authentication middleware for protected routes
13
- func AuthMiddleware() gin.HandlerFunc {
14
- return func(c *gin.Context) {
15
- authHeader := c.GetHeader("Authorization")
16
- if authHeader == "" {
17
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
18
- return
19
- }
52
+ // AuthContext is the normalized identity of the caller.
53
+ type AuthContext struct {
54
+ UserID string `json:"userId,omitempty"`
55
+ Email string `json:"email,omitempty"`
56
+ ServiceID string `json:"serviceId,omitempty"`
57
+ WorkspaceID string `json:"workspaceId,omitempty"`
58
+ OrganizationID string `json:"organizationId,omitempty"`
59
+ Roles []string `json:"roles"`
60
+ Claims map[string]interface{} `json:"claims,omitempty"`
61
+ }
20
62
 
21
- parts := strings.SplitN(authHeader, " ", 2)
22
- if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
23
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
24
- return
63
+ // AuthContextKey is the gin context key holding *AuthContext.
64
+ const AuthContextKey = "auth"
65
+
66
+ // GetAuthContext returns the authenticated caller, if any.
67
+ func GetAuthContext(c *gin.Context) (*AuthContext, bool) {
68
+ v, ok := c.Get(AuthContextKey)
69
+ if !ok {
70
+ return nil, false
71
+ }
72
+ a, ok := v.(*AuthContext)
73
+ return a, ok
74
+ }
75
+
76
+ func unauthorized(c *gin.Context, msg string) {
77
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": msg})
78
+ }
79
+ <% if (it.jwt) { %>
80
+
81
+ func splitList(s string) []string {
82
+ out := []string{}
83
+ for _, p := range strings.Split(s, ",") {
84
+ if p = strings.TrimSpace(p); p != "" {
85
+ out = append(out, p)
25
86
  }
87
+ }
88
+ return out
89
+ }
26
90
 
27
- token := parts[1]
91
+ func env(name, fallback string) string {
92
+ if v := os.Getenv(name); v != "" {
93
+ return v
94
+ }
95
+ return fallback
96
+ }
28
97
 
29
- // TODO: Validate the token
30
- // This is a placeholder - implement your actual token validation logic
31
- if token == "" {
32
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
33
- return
98
+ var (
99
+ issuer = env("AUTH_JWT_ISSUER", <%~ JSON.stringify(it.jwt.issuer) %>)
100
+ audience = env("AUTH_JWT_AUDIENCE", <%~ JSON.stringify(it.jwt.audience || '') %>)
101
+ jwksURI = env("AUTH_JWKS_URI", <%~ JSON.stringify(it.jwt.jwksUri) %>)
102
+ algorithms = []string{<%~ it.jwt.algorithms.map((a) => JSON.stringify(a)).join(', ') %>}
103
+ // Origins allowed in the azp claim; empty allows any.
104
+ authorizedParties = splitList(env("AUTH_JWT_AUTHORIZED_PARTIES", <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>))
105
+
106
+ jwksMu sync.Mutex
107
+ jwks keyfunc.Keyfunc
108
+ )
109
+
110
+ // keys fetches the issuer's JWKS on first use, retrying on later requests if
111
+ // the issuer was unreachable. keyfunc refreshes the set in the background.
112
+ func keys() (keyfunc.Keyfunc, error) {
113
+ jwksMu.Lock()
114
+ defer jwksMu.Unlock()
115
+ if jwks != nil {
116
+ return jwks, nil
117
+ }
118
+ k, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksURI})
119
+ if err != nil {
120
+ return nil, err
121
+ }
122
+ jwks = k
123
+ return k, nil
124
+ }
125
+
126
+ // claim reads a claim by exact name (Auth0 namespaced URLs contain dots),
127
+ // else by dotted path into nested objects.
128
+ func claim(m map[string]interface{}, path string) interface{} {
129
+ if path == "" {
130
+ return nil
131
+ }
132
+ if v, ok := m[path]; ok {
133
+ return v
134
+ }
135
+ var cur interface{} = m
136
+ for _, part := range strings.Split(path, ".") {
137
+ obj, ok := cur.(map[string]interface{})
138
+ if !ok {
139
+ return nil
34
140
  }
141
+ cur = obj[part]
142
+ }
143
+ return cur
144
+ }
35
145
 
36
- // Set user info in context if token is valid
37
- // c.Set("user_id", userId)
38
- // c.Set("user_roles", roles)
146
+ func str(v interface{}) string {
147
+ switch t := v.(type) {
148
+ case string:
149
+ return t
150
+ case float64:
151
+ return strconv.FormatFloat(t, 'f', -1, 64)
152
+ case json.Number:
153
+ return t.String()
154
+ }
155
+ return ""
156
+ }
39
157
 
40
- c.Next()
158
+ func contains(list []string, s string) bool {
159
+ for _, v := range list {
160
+ if v == s {
161
+ return true
162
+ }
163
+ }
164
+ return false
165
+ }
166
+
167
+ func roles(v interface{}) []string {
168
+ out := []string{}
169
+ switch t := v.(type) {
170
+ case []interface{}:
171
+ for _, r := range t {
172
+ if s := str(r); s != "" {
173
+ out = append(out, s)
174
+ }
175
+ }
176
+ case string:
177
+ if t != "" {
178
+ out = append(out, t)
179
+ }
41
180
  }
181
+ return out
42
182
  }
43
183
 
44
- <% if (it.auth && it.auth.roles && it.auth.roles.length > 0) { %>
45
- // RoleMiddleware checks if the user has the required role
46
- func RoleMiddleware(allowedRoles ...string) gin.HandlerFunc {
184
+ // AuthMiddleware verifies the caller's JWT. The database is unused for JWT auth.
185
+ func AuthMiddleware(_ *gorm.DB) gin.HandlerFunc {
47
186
  return func(c *gin.Context) {
48
- // Get user roles from context (set by AuthMiddleware)
49
- userRoles, exists := c.Get("user_roles")
50
- if !exists {
51
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "No roles found"})
187
+ header := c.GetHeader("Authorization")
188
+ if !strings.HasPrefix(header, "Bearer ") {
189
+ unauthorized(c, "No bearer token provided")
190
+ return
191
+ }
192
+ k, err := keys()
193
+ if err != nil {
194
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "Token signing keys unavailable"})
195
+ return
196
+ }
197
+ opts := []jwt.ParserOption{
198
+ jwt.WithIssuer(issuer),
199
+ jwt.WithValidMethods(algorithms),
200
+ jwt.WithExpirationRequired(),
201
+ }
202
+ <% if (it.jwt.provider !== 'cognito') { %>
203
+ if audience != "" {
204
+ opts = append(opts, jwt.WithAudience(audience))
205
+ }
206
+ <% } %>
207
+ mc := jwt.MapClaims{}
208
+ token, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), mc, k.Keyfunc, opts...)
209
+ if err != nil || !token.Valid {
210
+ unauthorized(c, "Invalid token")
52
211
  return
53
212
  }
213
+ claims := map[string]interface{}(mc)
214
+ <% if (it.jwt.provider === 'cognito') { %>
215
+ // Cognito ID tokens carry aud; access tokens carry client_id instead.
216
+ if audience != "" {
217
+ aud := claims["aud"]
218
+ if aud == nil {
219
+ aud = claims["client_id"]
220
+ }
221
+ if !containsAudience(aud, audience) {
222
+ unauthorized(c, "Invalid token")
223
+ return
224
+ }
225
+ }
226
+ <% } %>
54
227
 
55
- roles, ok := userRoles.([]string)
56
- if !ok {
57
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Invalid roles format"})
228
+ if azp, ok := claims["azp"].(string); ok && len(authorizedParties) > 0 && !contains(authorizedParties, azp) {
229
+ unauthorized(c, "Invalid token")
58
230
  return
59
231
  }
60
232
 
61
- // Check if user has any of the allowed roles
62
- for _, userRole := range roles {
63
- for _, allowedRole := range allowedRoles {
64
- if userRole == allowedRole {
65
- c.Next()
66
- return
67
- }
233
+ userID := str(claim(claims, <%~ JSON.stringify(it.jwt.claims.userId) %>))
234
+ if userID == "" {
235
+ unauthorized(c, "Token has no user id")
236
+ return
237
+ }
238
+ tenant := str(claim(claims, <%~ JSON.stringify(it.jwt.claims.workspaceId || '') %>))
239
+ if tenant == "" {
240
+ tenant = str(claim(claims, <%~ JSON.stringify(it.jwt.claims.organizationId || '') %>))
241
+ }
242
+ c.Set(AuthContextKey, &AuthContext{
243
+ UserID: userID,
244
+ Email: str(claim(claims, <%~ JSON.stringify(it.jwt.claims.email || '') %>)),
245
+ WorkspaceID: tenant,
246
+ OrganizationID: tenant,
247
+ Roles: roles(claim(claims, <%~ JSON.stringify(it.jwt.claims.roles || '') %>)),
248
+ Claims: claims,
249
+ })
250
+ c.Next()
251
+ }
252
+ }
253
+ <% if (it.jwt.provider === 'cognito') { %>
254
+
255
+ func containsAudience(aud interface{}, want string) bool {
256
+ switch t := aud.(type) {
257
+ case string:
258
+ return t == want
259
+ case []interface{}:
260
+ for _, a := range t {
261
+ if str(a) == want {
262
+ return true
68
263
  }
69
264
  }
265
+ }
266
+ return false
267
+ }
268
+ <% } %>
269
+ <% } else { %>
70
270
 
71
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
271
+ <% if (it.apiKey.hasExpiresAt) { %>
272
+ // before reports whether an expiresAt value read from the database is still
273
+ // in the future. A value it cannot read counts as expired.
274
+ func before(v interface{}, now time.Time) bool {
275
+ switch t := v.(type) {
276
+ case time.Time:
277
+ return now.Before(t)
278
+ case *time.Time:
279
+ return t == nil || now.Before(*t)
280
+ case string:
281
+ for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02"} {
282
+ if p, err := time.Parse(layout, t); err == nil {
283
+ return now.Before(p)
284
+ }
285
+ }
286
+ case []byte:
287
+ return before(string(t), now)
72
288
  }
289
+ return false
73
290
  }
74
291
 
75
- // Role constants
76
- const (
77
- <% it.auth.roles.forEach((role) => { %>
78
- Role<%= role.charAt(0).toUpperCase() + role.slice(1) %> = "<%= role %>"
79
- <% }) %>
80
- )
81
292
  <% } %>
82
-
83
- <% if (it.auth && it.auth.multiTenant) { %>
84
- // TenantMiddleware extracts and validates tenant from requests
85
- func TenantMiddleware() gin.HandlerFunc {
293
+ // AuthMiddleware authenticates the caller's API key against <%= it.apiKey.apiKeyEntity %>.
294
+ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc {
86
295
  return func(c *gin.Context) {
87
- tenantID := c.GetHeader("X-Tenant-ID")
88
- if tenantID == "" {
89
- tenantID = c.Query("tenantId")
296
+ key := c.GetHeader(<%~ JSON.stringify(it.apiKey.apiKeyHeader) %>)
297
+ if key == "" {
298
+ unauthorized(c, "No API key provided")
299
+ return
90
300
  }
91
-
92
- if tenantID == "" {
93
- c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Tenant ID required"})
301
+ sum := sha256.Sum256([]byte(key))
302
+ row := map[string]interface{}{}
303
+ err := db.Model(&models.<%= it.apiKey.goModel %>{}).
304
+ Where(<%~ JSON.stringify(it.apiKey.keyColumn + ' = ?') %>, hex.EncodeToString(sum[:])).
305
+ Take(&row).Error
306
+ if err != nil {
307
+ unauthorized(c, "Invalid API key")
94
308
  return
95
309
  }
96
-
97
- c.Set("tenant_id", tenantID)
310
+ <% if (it.apiKey.hasStatus) { %>
311
+ if fmt.Sprint(row["status"]) != "active" {
312
+ unauthorized(c, "API key is not active")
313
+ return
314
+ }
315
+ <% } %>
316
+ <% if (it.apiKey.hasExpiresAt) { %>
317
+ if v := row[<%~ JSON.stringify(it.apiKey.expiresColumn) %>]; v != nil && !before(v, time.Now()) {
318
+ unauthorized(c, "API key has expired")
319
+ return
320
+ }
321
+ <% } %>
322
+ tenant := ""
323
+ if v := row[<%~ JSON.stringify(it.apiKey.workspaceColumn) %>]; v != nil {
324
+ tenant = fmt.Sprint(v)
325
+ }
326
+ roles := []string{}
327
+ <% if (it.apiKey.roleField) { %>
328
+ if v := row[<%~ JSON.stringify(it.apiKey.roleColumn) %>]; v != nil {
329
+ roles = append(roles, fmt.Sprint(v))
330
+ }
331
+ <% } %>
332
+ c.Set(AuthContextKey, &AuthContext{
333
+ ServiceID: fmt.Sprint(row["id"]),
334
+ WorkspaceID: tenant,
335
+ OrganizationID: tenant,
336
+ Roles: roles,
337
+ })
98
338
  c.Next()
99
339
  }
100
340
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * AuthGuard - application API keys stored as SHA-256 hashes on <%= it.apiKey.apiKeyEntity %>
3
+ *
4
+ * Auto-generated by Apso CLI from .apsorc auth configuration
5
+ * Generated at: <%= it.generatedAt %>
6
+ *
7
+ * This guard:
8
+ * - Reads the key from the <%= it.apiKey.apiKeyHeader %> header
9
+ * - Looks up the <%= it.apiKey.apiKeyEntity %> row whose <%= it.apiKey.keyField %> is the key's hex SHA-256
10
+ <% if (it.apiKey.hasStatus) { %>
11
+ * - Rejects keys whose status is not "active"
12
+ <% } %>
13
+ <% if (it.apiKey.hasExpiresAt) { %>
14
+ * - Rejects keys past expiresAt
15
+ <% } %>
16
+ * - Attaches a normalized AuthContext to request.auth
17
+ *
18
+ * DO NOT EDIT - Changes will be overwritten on next scaffold
19
+ */
20
+ import {
21
+ Injectable,
22
+ CanActivate,
23
+ ExecutionContext,
24
+ UnauthorizedException,
25
+ } from '@nestjs/common';
26
+ import { Reflector } from '@nestjs/core';
27
+ import { InjectRepository } from '@nestjs/typeorm';
28
+ import { Repository } from 'typeorm';
29
+ import { createHash } from 'crypto';
30
+
31
+ import { <%= it.apiKey.apiKeyEntity %> } from '../<%= it.apiKey.apiKeyEntity %>/<%= it.apiKey.apiKeyEntity %>.entity';
32
+
33
+ export const IS_PUBLIC_KEY = 'isPublic';
34
+
35
+ /**
36
+ * Decorator to mark a route as public (skips authentication)
37
+ * Usage: @Public()
38
+ */
39
+ export const Public =
40
+ () => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
41
+ Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
42
+ };
43
+
44
+ /**
45
+ * Normalized auth context attached to authenticated requests.
46
+ * This is what scopeBy and RBAC consume.
47
+ */
48
+ export interface AuthContext {
49
+ userId?: string;
50
+ serviceId: string;
51
+ workspaceId?: string;
52
+ organizationId?: string;
53
+ roles: string[];
54
+ }
55
+
56
+ @Injectable()
57
+ export class AuthGuard implements CanActivate {
58
+ constructor(
59
+ private reflector: Reflector,
60
+ @InjectRepository(<%= it.apiKey.apiKeyEntity %>)
61
+ private apiKeyRepository: Repository<<%= it.apiKey.apiKeyEntity %>>,
62
+ ) {}
63
+
64
+ async canActivate(context: ExecutionContext): Promise<boolean> {
65
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
66
+ context.getHandler(),
67
+ context.getClass(),
68
+ ]);
69
+ if (isPublic) {
70
+ return true;
71
+ }
72
+
73
+ // The template's health check stays public for load balancer probes.
74
+ const req = context.switchToHttp().getRequest();
75
+ if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
76
+ return true;
77
+ }
78
+
79
+ const request = context.switchToHttp().getRequest();
80
+ const key = request.headers['<%= it.apiKey.apiKeyHeader %>'];
81
+ if (typeof key !== 'string' || !key) {
82
+ throw new UnauthorizedException('No API key provided');
83
+ }
84
+
85
+ const hash = createHash('sha256').update(key).digest('hex');
86
+ const record: any = await this.apiKeyRepository.findOne({
87
+ where: { <%= it.apiKey.keyField %>: hash } as any,
88
+ });
89
+ if (!record) {
90
+ throw new UnauthorizedException('Invalid API key');
91
+ }
92
+ <% if (it.apiKey.hasStatus) { %>
93
+ if (record.status !== 'active') {
94
+ throw new UnauthorizedException('API key is not active');
95
+ }
96
+ <% } %>
97
+ <% if (it.apiKey.hasExpiresAt) { %>
98
+ if (record.expiresAt && new Date(record.expiresAt) <= new Date()) {
99
+ throw new UnauthorizedException('API key has expired');
100
+ }
101
+ <% } %>
102
+
103
+ const tenant =
104
+ record.<%= it.apiKey.workspaceField %> != null
105
+ ? String(record.<%= it.apiKey.workspaceField %>)
106
+ : undefined;
107
+ <% if (it.apiKey.roleField) { %>
108
+ const role = record.<%= it.apiKey.roleField %>;
109
+ const roles = Array.isArray(role) ? role.map(String) : role ? [String(role)] : [];
110
+ <% } else { %>
111
+ const roles: string[] = [];
112
+ <% } %>
113
+ const auth: AuthContext = {
114
+ serviceId: String(record.id),
115
+ workspaceId: tenant,
116
+ organizationId: tenant,
117
+ roles,
118
+ };
119
+
120
+ request.auth = auth;
121
+ request.user = { id: auth.serviceId, ...auth };
122
+ // Read by ScopeGuard's scope lookup.
123
+ request.workspaceId = tenant;
124
+ request.organizationId = tenant;
125
+ return true;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Helper function to get AuthContext from request
131
+ * Use this in controllers/services to access the authenticated context
132
+ */
133
+ export function getAuthContext(request: any): AuthContext | undefined {
134
+ return request?.auth;
135
+ }
136
+
137
+ /**
138
+ * Helper function to require AuthContext (throws if not present)
139
+ */
140
+ export function requireAuthContext(request: any): AuthContext {
141
+ const ctx = getAuthContext(request);
142
+ if (!ctx) {
143
+ throw new UnauthorizedException('Authentication required');
144
+ }
145
+ return ctx;
146
+ }
@@ -94,6 +94,12 @@ export class AuthGuard implements CanActivate {
94
94
  return true;
95
95
  }
96
96
 
97
+ // The template's health check stays public for load balancer probes.
98
+ const req = context.switchToHttp().getRequest();
99
+ if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
100
+ return true;
101
+ }
102
+
97
103
  const request = context.switchToHttp().getRequest();
98
104
  const token = this.extractToken(request);
99
105
 
@@ -7,7 +7,7 @@ import { APP_GUARD } from '@nestjs/core';
7
7
  <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
8
8
  import { ScopeGuard } from './scope.guard';
9
9
  <% } %>
10
- <% if (it.authConfig) { %>
10
+ <% if (it.hasAuth) { %>
11
11
  import { AuthGuard } from './auth.guard';
12
12
  <% } %>
13
13
 
@@ -20,13 +20,11 @@ import { <%= entity.name %> } from '../<%= entity.name %>/<%= entity.name %>.ent
20
20
  <% } %>
21
21
 
22
22
  <% /* Import entities for AuthGuard (from sibling autogen folders) */ %>
23
- <% if (it.authConfig) { %>
23
+ <% if (it.authEntities.length > 0) { %>
24
24
  // Entities for AuthGuard
25
- import { <%= it.authConfig.sessionEntity %> } from '../<%= it.authConfig.sessionEntity %>/<%= it.authConfig.sessionEntity %>.entity';
26
- import { <%= it.authConfig.userEntity %> } from '../<%= it.authConfig.userEntity %>/<%= it.authConfig.userEntity %>.entity';
27
- <% if (it.authConfig.accountUserEntity) { %>
28
- import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
29
- <% } %>
25
+ <% it.authEntities.forEach((entity) => { %>
26
+ import { <%= entity %> } from '../<%= entity %>/<%= entity %>.entity';
27
+ <% }) %>
30
28
  <% } %>
31
29
 
32
30
  /**
@@ -43,12 +41,10 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
43
41
  * 3. Services with no auth model generate no guards and rely on the API Gateway
44
42
  * API key at the edge.
45
43
  *
46
- <% if (it.authConfig) { %>
44
+ <% if (it.hasAuth) { %>
47
45
  * AuthGuard:
48
- * - Validates session tokens from cookies/headers
49
- * - Loads user and organization context
46
+ * - Authenticates every request (<%= it.authProvider %>)
50
47
  * - Attaches normalized AuthContext to request.auth
51
- * - Provider: <%= it.authConfig.provider %>
52
48
  *
53
49
  <% } %>
54
50
  <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
@@ -72,11 +68,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
72
68
  <% /* Collect all unique entities */ %>
73
69
  <% const allEntities = new Set(); %>
74
70
  <% if (it.scopedEntities) { it.scopedEntities.forEach(e => allEntities.add(e.name)); } %>
75
- <% if (it.authConfig) { %>
76
- <% allEntities.add(it.authConfig.sessionEntity); %>
77
- <% allEntities.add(it.authConfig.userEntity); %>
78
- <% if (it.authConfig.accountUserEntity) { allEntities.add(it.authConfig.accountUserEntity); } %>
79
- <% } %>
71
+ <% it.authEntities.forEach(e => allEntities.add(e)); %>
80
72
  <% const entityArray = Array.from(allEntities); %>
81
73
  <% entityArray.forEach((entity, index) => { %>
82
74
  <%= entity %><%= index < entityArray.length - 1 ? ',' : '' %>
@@ -84,7 +76,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
84
76
  ]),
85
77
  ],
86
78
  providers: [
87
- <% if (it.authConfig) { %>
79
+ <% if (it.hasAuth) { %>
88
80
  AuthGuard,
89
81
  <% } %>
90
82
  <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
@@ -95,7 +87,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
95
87
  // config generate no guards at all and are protected at the edge by the
96
88
  // API Gateway API key — so this does not conflict with that setup. Use the
97
89
  // @Public() decorator to opt an individual route out of authentication.
98
- <% if (it.authConfig) { %>
90
+ <% if (it.hasAuth) { %>
99
91
  {
100
92
  provide: APP_GUARD,
101
93
  useClass: AuthGuard,
@@ -109,7 +101,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
109
101
  <% } %>
110
102
  ],
111
103
  exports: [
112
- <% if (it.authConfig) { %>
104
+ <% if (it.hasAuth) { %>
113
105
  AuthGuard,
114
106
  <% } %>
115
107
  <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
@@ -1,10 +1,10 @@
1
1
  <%~ includeFile('../header.eta') %>
2
2
 
3
- <% if (it.authConfig) { %>
3
+ <% if (it.hasAuth) { %>
4
4
  export * from './auth.guard';
5
5
  <% } %>
6
6
  <% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
7
- <% if (it.authConfig) { %>
7
+ <% if (it.hasAuth) { %>
8
8
  // When auth is configured, only export scope-specific items from scope.guard
9
9
  // to avoid duplicate exports (Public, IS_PUBLIC_KEY come from auth.guard)
10
10
  export {