@atlashub/smartstack-cli 3.28.0 → 3.29.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 (42) hide show
  1. package/dist/index.js +6 -7
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -3
  4. package/templates/project/api.ts.template +4 -2
  5. package/templates/project/appsettings.json.template +1 -1
  6. package/templates/skills/apex/_shared.md +13 -0
  7. package/templates/skills/apex/references/post-checks.md +228 -6
  8. package/templates/skills/apex/references/smartstack-api.md +67 -17
  9. package/templates/skills/apex/references/smartstack-frontend.md +41 -1
  10. package/templates/skills/apex/references/smartstack-layers.md +40 -10
  11. package/templates/skills/apex/steps/step-02-plan.md +16 -11
  12. package/templates/skills/apex/steps/step-03-execute.md +6 -0
  13. package/templates/skills/apex/steps/step-04-examine.md +4 -2
  14. package/templates/skills/application/references/frontend-verification.md +26 -1
  15. package/templates/skills/application/steps/step-03-roles.md +1 -1
  16. package/templates/skills/application/steps/step-05-frontend.md +24 -8
  17. package/templates/skills/application/templates-frontend.md +41 -22
  18. package/templates/skills/application/templates-seed.md +53 -16
  19. package/templates/skills/business-analyse/SKILL.md +4 -2
  20. package/templates/skills/business-analyse/_shared.md +17 -4
  21. package/templates/skills/business-analyse/react/schema.md +1 -1
  22. package/templates/skills/business-analyse/references/agent-module-prompt.md +11 -9
  23. package/templates/skills/business-analyse/references/consolidation-structural-checks.md +4 -3
  24. package/templates/skills/business-analyse/references/deploy-modes.md +1 -1
  25. package/templates/skills/business-analyse/references/handoff-file-templates.md +4 -4
  26. package/templates/skills/business-analyse/references/robustness-checks.md +12 -9
  27. package/templates/skills/business-analyse/references/spec-auto-inference.md +3 -3
  28. package/templates/skills/business-analyse/references/ui-resource-cards.md +3 -3
  29. package/templates/skills/business-analyse/references/validation-checklist.md +21 -3
  30. package/templates/skills/business-analyse/schemas/sections/specification-schema.json +33 -5
  31. package/templates/skills/business-analyse/steps/step-03b-ui.md +2 -2
  32. package/templates/skills/business-analyse/steps/step-03c-compile.md +17 -9
  33. package/templates/skills/business-analyse/steps/step-03d-validate.md +1 -1
  34. package/templates/skills/business-analyse/steps/step-04b-analyze.md +5 -3
  35. package/templates/skills/business-analyse/steps/step-05a-handoff.md +23 -15
  36. package/templates/skills/business-analyse/templates/tpl-handoff.md +10 -8
  37. package/templates/skills/business-analyse/templates/tpl-progress.md +7 -6
  38. package/templates/skills/ralph-loop/references/category-rules.md +50 -6
  39. package/templates/skills/ralph-loop/references/compact-loop.md +16 -1
  40. package/templates/skills/ralph-loop/references/core-seed-data.md +158 -38
  41. package/templates/skills/ralph-loop/references/task-transform-legacy.md +3 -3
  42. package/templates/skills/ralph-loop/steps/step-02-execute.md +109 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlashub/smartstack-cli",
3
- "version": "3.28.0",
3
+ "version": "3.29.0",
4
4
  "description": "SmartStack Claude Code automation toolkit - GitFlow, EF Core migrations, prompts and more",
5
5
  "author": {
6
6
  "name": "SmartStack",
@@ -111,6 +111,5 @@
111
111
  "tsup": "^8.0.1",
112
112
  "typescript": "^5.3.3",
113
113
  "vitest": "^2.1.0"
114
- },
115
- "optionalDependencies": {}
114
+ }
116
115
  }
@@ -1,10 +1,12 @@
1
1
  // Re-export SmartStack's shared API client
2
2
  // IMPORTANT: Do NOT create a custom axios instance — use the SmartStack-provided client
3
3
  // which handles authentication, token refresh, and session management automatically.
4
- import { apiClient } from '@atlashub/smartstack';
4
+ import { apiClient, api } from '@atlashub/smartstack';
5
5
 
6
+ export { api };
6
7
  export default apiClient;
7
8
 
8
9
  // For module-specific API calls, extend from the shared client:
9
10
  // import apiClient from './api';
10
- // export const getEmployees = () => apiClient.get('/api/business/humanresources/employees');
11
+ // import { api } from './api';
12
+ // export const getEmployees = () => api.get('/api/business/human-resources/employees');
@@ -43,7 +43,7 @@
43
43
  "EnableValidation": true,
44
44
  "RefreshTokenExpirationDays": 7,
45
45
  "CleanupBatchSize": 100,
46
- "MaxConcurrentSessions": 1,
46
+ "MaxConcurrentSessions": 3,
47
47
  "CleanupIntervalMinutes": 5
48
48
  },
49
49
  "Serilog": {
@@ -32,6 +32,19 @@
32
32
  |------|---------|------|
33
33
  | `validate_conventions` | Validate SmartStack conventions | 00 (check), 04 (examine) |
34
34
 
35
+ ### Permission Path Format
36
+
37
+ | Level | Permission format | Segments |
38
+ |-------|------------------|----------|
39
+ | Module | `{context}.{app}.{module}.{action}` | 3+1 segments |
40
+ | Section | `{context}.{app}.{module}.{section}.{action}` | 4+1 segments |
41
+ | Resource | `{context}.{app}.{module}.{section}.{resource}.{action}` | 5+1 segments |
42
+
43
+ **Examples:**
44
+ - Module: `business.humanresources.employees.read` (3+1)
45
+ - Section: `business.humanresources.employees.departments.read` (4+1)
46
+ - Resource: `business.humanresources.employees.departments.export.execute` (5+1)
47
+
35
48
  ### Generation (step-03)
36
49
 
37
50
  | Tool | Purpose | Condition |
@@ -41,7 +41,7 @@ if [ -n "$SERVICE_FILES" ]; then
41
41
  fi
42
42
  ```
43
43
 
44
- ### POST-CHECK 3: Controllers must use [RequirePermission], not just [Authorize]
44
+ ### POST-CHECK 3: Controllers must use [RequirePermission], not just [Authorize] (BLOCKING)
45
45
 
46
46
  ```bash
47
47
  # Find all controller files
@@ -50,8 +50,10 @@ if [ -n "$CTRL_FILES" ]; then
50
50
  for f in $CTRL_FILES; do
51
51
  # Check controller has at least one RequirePermission attribute
52
52
  if grep -q "\[Authorize\]" "$f" && ! grep -q "\[RequirePermission" "$f"; then
53
- echo "WARNING: Controller uses [Authorize] without [RequirePermission]: $f"
54
- echo "Use [RequirePermission(Permissions.{Module}.{Action})] on each endpoint"
53
+ echo "BLOCKING: Controller uses [Authorize] without [RequirePermission]: $f"
54
+ echo "[Authorize] alone provides NO RBAC enforcement — any authenticated user has access"
55
+ echo "Fix: Add [RequirePermission(Permissions.{Module}.{Action})] on each endpoint"
56
+ exit 1
55
57
  fi
56
58
  done
57
59
  fi
@@ -271,6 +273,10 @@ if [ -n "$APP_TSX" ] && [ -n "$SEED_ROUTES" ]; then
271
273
  SEED_NORM=$(echo "$SEED_SUFFIX" | tr '[:upper:]' '[:lower:]' | tr -d '-')
272
274
  MATCH_FOUND=false
273
275
  for FE_PATH in $FRONTEND_PATHS; do
276
+ # Flag FORBIDDEN /list suffix BEFORE normalization
277
+ if echo "$FE_PATH" | grep -qP '/list$'; then
278
+ echo "WARNING: Frontend route ends with /list — should use index route instead: $FE_PATH"
279
+ fi
274
280
  FE_BASE=$(echo "$FE_PATH" | sed 's|/list$||;s|/new$||;s|/:id.*||;s|/create$||')
275
281
  FE_NORM=$(echo "$FE_BASE" | tr '[:upper:]' '[:lower:]' | tr -d '-')
276
282
  if [ "$SEED_NORM" = "$FE_NORM" ]; then
@@ -291,6 +297,37 @@ if [ -n "$APP_TSX" ] && [ -n "$SEED_ROUTES" ]; then
291
297
  fi
292
298
  ```
293
299
 
300
+ ### POST-CHECK 14b: Frontend routes must use kebab-case (BLOCKING)
301
+
302
+ ```bash
303
+ # POST-CHECK 14 normalizes hyphens for existence check, but does NOT catch kebab-case mismatches.
304
+ # This supplementary check detects concatenated multi-word route segments without hyphens.
305
+ APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
306
+ if [ -n "$APP_TSX" ]; then
307
+ # Extract route path strings from App.tsx
308
+ FE_PATHS=$(grep -oP "path:\s*['\"]([^'\"]+)['\"]" "$APP_TSX" | grep -oP "['\"][^'\"]+['\"]" | tr -d "'" | tr -d '"')
309
+ for FE_PATH in $FE_PATHS; do
310
+ # Split path by / and check each segment
311
+ for SEG in $(echo "$FE_PATH" | tr '/' '\n'); do
312
+ # Skip dynamic segments (:id, :slug) and single words (< 10 chars likely single word)
313
+ echo "$SEG" | grep -qP '^:' && continue
314
+ # Detect multi-word segments without hyphens: 2+ consecutive lowercase sequences
315
+ # e.g., "humanresources" (human+resources), "timemanagement" (time+management)
316
+ if echo "$SEG" | grep -qP '^[a-z]{10,}$'; then
317
+ # Potential concatenated multi-word — cross-check with seed data
318
+ SEED_MATCH=$(echo "$SEED_ROUTES" | tr '/' '\n' | grep -P "^[a-z]+-[a-z]+" | tr -d '-' | grep -x "$SEG")
319
+ if [ -n "$SEED_MATCH" ]; then
320
+ echo "BLOCKING: Frontend route segment '$SEG' appears to be missing hyphens"
321
+ echo "Seed data uses kebab-case (e.g., 'human-resources') but frontend has '$SEG'"
322
+ echo "Fix: Use kebab-case in App.tsx route paths to match seed data exactly"
323
+ exit 1
324
+ fi
325
+ fi
326
+ done
327
+ done
328
+ fi
329
+ ```
330
+
294
331
  ### POST-CHECK 15: HasQueryFilter must not use Guid.Empty (OWASP A01)
295
332
 
296
333
  ```bash
@@ -441,17 +478,202 @@ SERVICE_FILES=$(find src/ -path "*/Services/*" -name "*Service.cs" ! -name "I*Se
441
478
  if [ -n "$SERVICE_FILES" ]; then
442
479
  BAD_PATTERN=$(grep -Pn 'TenantId!\s*\.Value|TenantId!\s*\.ToString|\.TenantId!' $SERVICE_FILES 2>/dev/null)
443
480
  if [ -n "$BAD_PATTERN" ]; then
444
- echo "BLOCKING: Services use TenantId!.Value — causes 500 instead of 401 when tenant context is missing"
481
+ echo "BLOCKING: Services use TenantId!.Value — causes 500 instead of 400 when tenant context is missing"
445
482
  echo "$BAD_PATTERN"
446
483
  echo ""
447
484
  echo "Fix: Replace with guard clause at the start of every method:"
448
485
  echo " var tenantId = _currentTenant.TenantId"
449
- echo " ?? throw new UnauthorizedAccessException(\"Tenant context is required\");"
486
+ echo " ?? throw new TenantContextRequiredException();"
450
487
  echo ""
451
- echo "This produces a clean 401 via GlobalExceptionHandlerMiddleware instead of an opaque 500."
488
+ echo "This produces a clean 400 Bad Request via GlobalExceptionHandlerMiddleware."
489
+ echo "NEVER use UnauthorizedAccessException for tenant context — it returns 401 which clears the frontend token."
490
+ exit 1
491
+ fi
492
+ fi
493
+
494
+ # POST-CHECK: Services must NOT use UnauthorizedAccessException for tenant context (causes token clearing)
495
+ if [ -n "$SERVICE_FILES" ]; then
496
+ BAD_UNAUTH=$(grep -Pn 'UnauthorizedAccessException.*[Tt]enant' $SERVICE_FILES 2>/dev/null)
497
+ if [ -n "$BAD_UNAUTH" ]; then
498
+ echo "BLOCKING: Services use UnauthorizedAccessException for tenant context — causes 401 which clears the frontend token"
499
+ echo "$BAD_UNAUTH"
500
+ echo ""
501
+ echo "Fix: Replace with:"
502
+ echo " var tenantId = _currentTenant.TenantId"
503
+ echo " ?? throw new TenantContextRequiredException();"
504
+ echo ""
505
+ echo "TenantContextRequiredException returns 400 Bad Request (does not clear token)."
506
+ echo "UnauthorizedAccessException returns 401 Unauthorized (clears token + redirects to login)."
507
+ exit 1
508
+ fi
509
+ fi
510
+ ```
511
+
512
+ ### POST-CHECK 22: Permissions.cs static constants must exist (BLOCKING)
513
+
514
+ ```bash
515
+ # Every module with controllers MUST have a Permissions.cs with static constants
516
+ CTRL_FILES=$(find src/ -path "*/Controllers/*" -name "*Controller.cs" 2>/dev/null)
517
+ if [ -n "$CTRL_FILES" ]; then
518
+ PERM_REFS=$(grep -ohP 'Permissions\.\w+\.\w+' $CTRL_FILES 2>/dev/null | sed 's/Permissions\.\([^.]*\)\..*/\1/' | sort -u)
519
+ for MODULE in $PERM_REFS; do
520
+ PERM_FILE=$(find src/ -name "Permissions.cs" -exec grep -l "static class $MODULE" {} \; 2>/dev/null)
521
+ if [ -z "$PERM_FILE" ]; then
522
+ echo "BLOCKING: Controller references Permissions.${MODULE}.* but no Permissions.cs defines static class ${MODULE}"
523
+ echo "Fix: Create Application/Authorization/Permissions.cs with: public static class ${MODULE} { public const string Read = \"...\"; ... }"
524
+ exit 1
525
+ fi
526
+ done
527
+ fi
528
+ ```
529
+
530
+ ### POST-CHECK 23: ApplicationRolesSeedData.cs must exist (BLOCKING)
531
+
532
+ ```bash
533
+ # If any RolesSeedData exists, ApplicationRolesSeedData MUST also exist
534
+ ROLE_SEED=$(find src/ -path "*/Seeding/Data/*" -name "*RolesSeedData.cs" 2>/dev/null | head -1)
535
+ if [ -n "$ROLE_SEED" ]; then
536
+ APP_ROLE_SEED=$(find src/ -path "*/Seeding/Data/ApplicationRolesSeedData.cs" 2>/dev/null | head -1)
537
+ if [ -z "$APP_ROLE_SEED" ]; then
538
+ echo "BLOCKING: RolesSeedData exists but ApplicationRolesSeedData.cs NOT FOUND"
539
+ echo "ApplicationRolesSeedData defines the 4 application-scoped roles (admin, manager, contributor, viewer)"
540
+ echo "Without it, SeedRolesAsync() has no role entries to create → RBAC broken"
541
+ echo "Fix: Create src/Infrastructure/Persistence/Seeding/Data/ApplicationRolesSeedData.cs"
452
542
  exit 1
453
543
  fi
454
544
  fi
455
545
  ```
456
546
 
547
+ ### POST-CHECK 24b: Section route completeness (NavigationSection → frontend route + permissions)
548
+
549
+ ```bash
550
+ # Every NavigationSection seed data route MUST have a corresponding frontend route in App.tsx
551
+ # and section-level permissions MUST exist for each section defined in seed data
552
+ SECTION_SEED_FILES=$(find src/ -path "*/Seeding/Data/*" -name "*NavigationSectionSeedData.cs" 2>/dev/null)
553
+ APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
554
+ if [ -n "$SECTION_SEED_FILES" ] && [ -n "$APP_TSX" ]; then
555
+ # Extract section routes from seed data
556
+ SECTION_ROUTES=$(grep -Poh '"/[a-z][a-z0-9/-]+"' $SECTION_SEED_FILES 2>/dev/null | tr -d '"' | sort -u)
557
+ for SECTION_ROUTE in $SECTION_ROUTES; do
558
+ # Extract the last segment (section-kebab) for frontend route matching
559
+ SECTION_SEG=$(echo "$SECTION_ROUTE" | rev | cut -d'/' -f1 | rev)
560
+ if ! grep -q "'$SECTION_SEG'" "$APP_TSX" && ! grep -q "\"$SECTION_SEG\"" "$APP_TSX"; then
561
+ echo "BLOCKING: NavigationSection seed data route has no matching frontend route: $SECTION_ROUTE"
562
+ echo "Expected path segment '$SECTION_SEG' in App.tsx contextRoutes"
563
+ echo "Fix: Add section child routes to the module's children array in App.tsx"
564
+ fi
565
+ done
566
+ fi
567
+
568
+ # Controllers with section-level [NavRoute] (4 segments) must have matching [RequirePermission]
569
+ CTRL_FILES=$(find src/ -path "*/Controllers/*" -name "*Controller.cs" 2>/dev/null)
570
+ if [ -n "$CTRL_FILES" ]; then
571
+ for f in $CTRL_FILES; do
572
+ # Match NavRoute with 4 dot-separated segments (section-level)
573
+ SECTION_NAVROUTE=$(grep -oP 'NavRoute\("[a-z]+\.[a-z]+\.[a-z]+\.[a-z]+"\)' "$f" 2>/dev/null)
574
+ if [ -n "$SECTION_NAVROUTE" ] && ! grep -q "\[RequirePermission" "$f"; then
575
+ echo "BLOCKING: Section controller has [NavRoute] but no [RequirePermission]: $f"
576
+ echo "Fix: Add [RequirePermission(Permissions.{Section}.{Action})] on each endpoint"
577
+ exit 1
578
+ fi
579
+ done
580
+ fi
581
+
582
+ # Section-level permissions must exist for each section in seed data
583
+ PERM_FILE=$(find src/ -name "Permissions.cs" -path "*/Authorization/*" 2>/dev/null | head -1)
584
+ if [ -n "$SECTION_SEED_FILES" ] && [ -n "$PERM_FILE" ]; then
585
+ SECTION_CODES=$(grep -oP 'Code\s*=\s*"([a-z]+)"' $SECTION_SEED_FILES 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' | sort -u)
586
+ for CODE in $SECTION_CODES; do
587
+ PASCAL=$(echo "$CODE" | sed 's/^./\U&/')
588
+ if ! grep -q "static class $PASCAL" "$PERM_FILE" 2>/dev/null; then
589
+ echo "WARNING: Section '$CODE' in seed data has no matching Permissions.$PASCAL static class"
590
+ echo "Fix: Add section-level permissions via MCP generate_permissions with 4-segment navRoute"
591
+ fi
592
+ done
593
+ fi
594
+ ```
595
+
596
+ ### POST-CHECK 25: FORBIDDEN route patterns — /list and /detail/:id (BLOCKING)
597
+
598
+ ```bash
599
+ # 1. Check seed data for FORBIDDEN suffixes
600
+ SEED_NAV_FILES=$(find src/ -path "*/Seeding/Data/*" -name "*NavigationSeedData.cs" -o -name "*NavigationSectionSeedData.cs" 2>/dev/null)
601
+ if [ -n "$SEED_NAV_FILES" ]; then
602
+ BAD_ROUTES=$(grep -Pn 'Route\s*=\s*.*"[^"]*/(list|detail)["/]' $SEED_NAV_FILES 2>/dev/null | grep -v '//.*Route')
603
+ if [ -n "$BAD_ROUTES" ]; then
604
+ echo "BLOCKING: FORBIDDEN route pattern in seed data"
605
+ echo " - 'list' section route = module route (NO /list suffix)"
606
+ echo " - 'detail' section route = module route + /:id (NOT /detail/:id)"
607
+ echo "$BAD_ROUTES"
608
+ exit 1
609
+ fi
610
+ fi
611
+
612
+ # 2. Check frontend routes for FORBIDDEN path segments
613
+ APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
614
+ if [ -n "$APP_TSX" ]; then
615
+ BAD_FE=$(grep -Pn "path:\s*['\"](?:list|detail)" "$APP_TSX" 2>/dev/null)
616
+ if [ -n "$BAD_FE" ]; then
617
+ echo "BLOCKING: FORBIDDEN frontend route path"
618
+ echo " - list = index: true (no 'list' path segment)"
619
+ echo " - detail = ':id' (no 'detail' path segment)"
620
+ echo "$BAD_FE"
621
+ exit 1
622
+ fi
623
+ fi
624
+ echo "OK: No forbidden /list or /detail route patterns found"
625
+ ```
626
+
627
+ ### POST-CHECK 26: Permission path segment count (WARNING)
628
+
629
+ ```bash
630
+ PERM_FILES=$(find src/ -path "*/Seeding/Data/*" -name "PermissionsSeedData.cs" 2>/dev/null)
631
+ if [ -n "$PERM_FILES" ]; then
632
+ while IFS= read -r line; do
633
+ PATH_VAL=$(echo "$line" | grep -oP '"[^"]*\.[^"]*"' | tr -d '"')
634
+ if [ -n "$PATH_VAL" ]; then
635
+ DOTS=$(echo "$PATH_VAL" | tr -cd '.' | wc -c)
636
+ # Module permissions: 3 dots (context.app.module.action = 4 segments = 3+1)
637
+ # Section permissions: 4 dots (context.app.module.section.action = 5 segments = 4+1)
638
+ # Wildcard: ends with .* (valid at any level)
639
+ if echo "$PATH_VAL" | grep -qP '\.\*$'; then
640
+ continue # Wildcards are valid
641
+ elif [ "$DOTS" -lt 3 ] || [ "$DOTS" -gt 5 ]; then
642
+ echo "WARNING: Permission path has unexpected segment count ($((DOTS+1)) segments): $PATH_VAL"
643
+ fi
644
+ fi
645
+ done < <(grep -n 'Path\s*=' $PERM_FILES 2>/dev/null)
646
+ fi
647
+ ```
648
+
649
+ ### POST-CHECK 24: IClientSeedDataProvider must have 4 methods + DI registration (BLOCKING)
650
+
651
+ ```bash
652
+ PROVIDER=$(find src/ -path "*/Seeding/*SeedDataProvider.cs" 2>/dev/null | head -1)
653
+ if [ -n "$PROVIDER" ]; then
654
+ METHODS_FOUND=0
655
+ for METHOD in SeedNavigationAsync SeedRolesAsync SeedPermissionsAsync SeedRolePermissionsAsync; do
656
+ if grep -q "$METHOD" "$PROVIDER"; then
657
+ METHODS_FOUND=$((METHODS_FOUND + 1))
658
+ else
659
+ echo "BLOCKING: IClientSeedDataProvider missing method: $METHOD in $PROVIDER"
660
+ fi
661
+ done
662
+ if [ "$METHODS_FOUND" -lt 4 ]; then
663
+ echo "Fix: IClientSeedDataProvider must implement all 4 methods: SeedNavigationAsync, SeedRolesAsync, SeedPermissionsAsync, SeedRolePermissionsAsync"
664
+ exit 1
665
+ fi
666
+
667
+ # Check DI registration
668
+ DI_FILE=$(find src/ -name "DependencyInjection.cs" -path "*/Infrastructure/*" 2>/dev/null | head -1)
669
+ if [ -n "$DI_FILE" ]; then
670
+ if ! grep -q "IClientSeedDataProvider" "$DI_FILE"; then
671
+ echo "BLOCKING: IClientSeedDataProvider not registered in DependencyInjection.cs"
672
+ echo "Fix: Add services.AddScoped<IClientSeedDataProvider, {App}SeedDataProvider>()"
673
+ exit 1
674
+ fi
675
+ fi
676
+ fi
677
+ ```
678
+
457
679
  **If ANY POST-CHECK fails → fix in step-03, re-validate.**
@@ -238,9 +238,9 @@ public class {Name}Service : I{Name}Service
238
238
  int pageSize = 20,
239
239
  CancellationToken ct = default)
240
240
  {
241
- // MANDATORY guard — throws 401 if no tenant context (e.g., missing X-Tenant-Slug header)
241
+ // MANDATORY guard — throws 400 if no tenant context (e.g., missing X-Tenant-Slug header)
242
242
  var tenantId = _currentTenant.TenantId
243
- ?? throw new UnauthorizedAccessException("Tenant context is required");
243
+ ?? throw new TenantContextRequiredException();
244
244
 
245
245
  var query = _db.{Name}s
246
246
  .Where(x => x.TenantId == tenantId) // MANDATORY tenant filter
@@ -268,7 +268,7 @@ public class {Name}Service : I{Name}Service
268
268
  public async Task<{Name}ResponseDto?> GetByIdAsync(Guid id, CancellationToken ct)
269
269
  {
270
270
  var tenantId = _currentTenant.TenantId
271
- ?? throw new UnauthorizedAccessException("Tenant context is required");
271
+ ?? throw new TenantContextRequiredException();
272
272
 
273
273
  return await _db.{Name}s
274
274
  .Where(x => x.Id == id && x.TenantId == tenantId) // MANDATORY
@@ -280,7 +280,7 @@ public class {Name}Service : I{Name}Service
280
280
  public async Task<{Name}ResponseDto> CreateAsync(Create{Name}Dto dto, CancellationToken ct)
281
281
  {
282
282
  var tenantId = _currentTenant.TenantId
283
- ?? throw new UnauthorizedAccessException("Tenant context is required");
283
+ ?? throw new TenantContextRequiredException();
284
284
 
285
285
  var entity = {Name}.Create(
286
286
  tenantId: tenantId, // MANDATORY — never Guid.Empty
@@ -301,7 +301,7 @@ public class {Name}Service : I{Name}Service
301
301
  public async Task DeleteAsync(Guid id, CancellationToken ct)
302
302
  {
303
303
  var tenantId = _currentTenant.TenantId
304
- ?? throw new UnauthorizedAccessException("Tenant context is required");
304
+ ?? throw new TenantContextRequiredException();
305
305
 
306
306
  var entity = await _db.{Name}s
307
307
  .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct)
@@ -321,12 +321,14 @@ public class {Name}Service : I{Name}Service
321
321
  **MANDATORY guard clause (first line of every method):**
322
322
  ```csharp
323
323
  var tenantId = _currentTenant.TenantId
324
- ?? throw new UnauthorizedAccessException("Tenant context is required");
324
+ ?? throw new TenantContextRequiredException();
325
325
  ```
326
- This converts a null TenantId into a clean 401 response via `GlobalExceptionHandlerMiddleware`.
326
+ This converts a null TenantId into a clean 400 Bad Request response via `GlobalExceptionHandlerMiddleware`.
327
+ **IMPORTANT:** Uses `TenantContextRequiredException` (400), NOT `UnauthorizedAccessException` (401). A missing tenant is a bad request, not an auth failure — the JWT is valid, `[Authorize]` passed.
327
328
 
328
329
  **FORBIDDEN in services:**
329
- - `_currentTenant.TenantId!.Value` — throws `InvalidOperationException` (500) instead of clean 401
330
+ - `_currentTenant.TenantId!.Value` — throws `InvalidOperationException` (500) instead of clean 400
331
+ - `UnauthorizedAccessException("Tenant context is required")` — throws 401, triggers frontend token clearing
330
332
  - `tenantId: Guid.Empty` — always use validated tenantId from guard clause
331
333
  - Queries WITHOUT `.Where(x => x.TenantId == tenantId)` — data leak
332
334
  - Missing `ILogger<T>` — undiagnosable in production
@@ -383,6 +385,14 @@ public class {Name}Controller : ControllerBase
383
385
  return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
384
386
  }
385
387
 
388
+ [HttpPut("{id:guid}")]
389
+ [RequirePermission(Permissions.{Module}.Update)]
390
+ public async Task<ActionResult<{Name}ResponseDto>> Update(Guid id, [FromBody] Update{Name}Dto dto, CancellationToken ct)
391
+ {
392
+ var result = await _service.UpdateAsync(id, dto, ct);
393
+ return result is null ? NotFound() : Ok(result);
394
+ }
395
+
386
396
  [HttpDelete("{id:guid}")]
387
397
  [RequirePermission(Permissions.{Module}.Delete)]
388
398
  public async Task<ActionResult> Delete(Guid id, CancellationToken ct)
@@ -395,6 +405,35 @@ public class {Name}Controller : ControllerBase
395
405
 
396
406
  **CRITICAL:** Use `[RequirePermission(Permissions.{Module}.{Action})]` on EVERY endpoint — NEVER `[Authorize]` alone (no RBAC enforcement).
397
407
 
408
+ ### Section-Level Controller (NavRoute with 4 segments)
409
+
410
+ When a module has sections, each section gets its own controller with a 4-segment navRoute:
411
+
412
+ ```csharp
413
+ // Section-level controller: navRoute has 4 segments
414
+ [ApiController]
415
+ [NavRoute("{context}.{app}.{module}.{section}")]
416
+ [Authorize]
417
+ public class {Section}Controller : ControllerBase
418
+ {
419
+ // Example: business.humanresources.employees.departments
420
+ [HttpGet]
421
+ [RequirePermission(Permissions.{Section}.Read)]
422
+ public async Task<ActionResult<PaginatedResult<{Section}ResponseDto>>> GetAll(
423
+ [FromQuery] string? search = null,
424
+ [FromQuery] int page = 1,
425
+ [FromQuery] int pageSize = 20,
426
+ CancellationToken ct = default)
427
+ => Ok(await _service.GetAllAsync(search, page, pageSize, ct));
428
+ }
429
+ ```
430
+
431
+ **NavRoute segment rules:**
432
+ | Level | NavRoute format | Example |
433
+ |-------|----------------|---------|
434
+ | Module | `{context}.{app}.{module}` (3 segments) | `business.humanresources.employees` |
435
+ | Section | `{context}.{app}.{module}.{section}` (4 segments) | `business.humanresources.employees.departments` |
436
+
398
437
  **Namespace:** `SmartStack.Api.Routing` (NOT `SmartStack.Api.Core.Routing`)
399
438
 
400
439
  **NavRoute resolves at startup from DB:** `platform.administration.users` → `api/platform/administration/users`
@@ -412,8 +451,16 @@ public class {Name}Controller : ControllerBase
412
451
  |-------|-------------|---------|
413
452
  | Application | `/{context}/{app-kebab}` | `/business/human-resources` |
414
453
  | Module | `/{context}/{app-kebab}/{module-kebab}` | `/business/human-resources/employees` |
415
- | Section | `/{context}/{app-kebab}/{module-kebab}/{section-kebab}` | `/business/human-resources/employees/list` |
416
- | Resource | `/{context}/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/business/human-resources/employees/list/export` |
454
+ | Section | `/{context}/{app-kebab}/{module-kebab}/{section-kebab}` | `/business/human-resources/employees/departments` |
455
+ | Resource | `/{context}/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/business/human-resources/employees/departments/export` |
456
+
457
+ **ROUTE SPECIAL CASES (list and detail sections):**
458
+ > The `list` and `detail` sections are NOT functional sub-areas — they are view modes of the module itself.
459
+ > Their navigation routes MUST NOT add extra segments:
460
+ > - `list` section route = module route (e.g., `/business/human-resources/employees`)
461
+ > - `detail` section route = module route + `/:id` (e.g., `/business/human-resources/employees/:id`)
462
+ > - FORBIDDEN: `/employees/list`, `/employees/detail/:id`
463
+ > - Other sections (dashboard, approve, import, etc.) = module route + `/{section-kebab}` (normal behavior)
417
464
 
418
465
  **Rules:**
419
466
  - Routes ALWAYS start with `/`
@@ -426,7 +473,7 @@ public class {Name}Controller : ControllerBase
426
473
  ```csharp
427
474
  private static string ToKebabCase(string value)
428
475
  => System.Text.RegularExpressions.Regex
429
- .Replace(value, "(?<!^)([A-Z])", "-$1")
476
+ .Replace(value, "([a-z])([A-Z])", "$1-$2")
430
477
  .ToLowerInvariant();
431
478
  ```
432
479
 
@@ -446,7 +493,7 @@ public static class SeedConstants
446
493
  // Context IDs are NOT — they are pre-seeded by SmartStack core.
447
494
  public static readonly Guid ApplicationId = DeterministicGuid("nav:business.humanresources");
448
495
  public static readonly Guid ModuleId = DeterministicGuid("nav:business.humanresources.employees");
449
- public static readonly Guid SectionId = DeterministicGuid("nav:business.humanresources.employees.list");
496
+ public static readonly Guid SectionId = DeterministicGuid("nav:business.humanresources.employees.departments");
450
497
 
451
498
  // FORBIDDEN — Context IDs are NOT deterministic, they come from SmartStack core:
452
499
  // public static readonly Guid BusinessContextId = DeterministicGuid("nav:business"); // WRONG!
@@ -545,7 +592,8 @@ services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
545
592
  | `tenantId: Guid.Empty` in services | OWASP A01 — always use validated `_currentTenant.TenantId` |
546
593
  | Service without `ICurrentTenantService` | All tenant data leaks — inject `ICurrentTenantService` |
547
594
  | `ICurrentUser` in service code | Does NOT exist — use `ICurrentUserService` + `ICurrentTenantService` |
548
- | `_currentTenant.TenantId!.Value` | Crashes with 500 — use `?? throw new UnauthorizedAccessException(...)` |
595
+ | `_currentTenant.TenantId!.Value` | Crashes with 500 — use `?? throw new TenantContextRequiredException()` |
596
+ | `UnauthorizedAccessException("Tenant context is required")` | Returns 401 → clears frontend token. Use `TenantContextRequiredException()` (400) |
549
597
  | Route `"humanresources"` in seed data | Must be full path `"/business/human-resources"` |
550
598
  | Route without leading `/` | All routes must start with `/` |
551
599
  | `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
@@ -712,16 +760,18 @@ public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
712
760
  }
713
761
  ```
714
762
 
715
- **CORRECT — Clean 401 via GlobalExceptionHandlerMiddleware:**
763
+ **CORRECT — Clean 400 via GlobalExceptionHandlerMiddleware:**
716
764
  ```csharp
717
- // CORRECT: Throws UnauthorizedAccessException → middleware converts to 401
765
+ // CORRECT: Throws TenantContextRequiredException → middleware converts to 400 Bad Request
718
766
  public async Task<PaginatedResult<MyEntityDto>> GetAllAsync(...)
719
767
  {
720
768
  var tenantId = _currentTenant.TenantId
721
- ?? throw new UnauthorizedAccessException("Tenant context is required");
769
+ ?? throw new TenantContextRequiredException();
722
770
  var query = _db.MyEntities.Where(x => x.TenantId == tenantId);
723
771
  // ...
724
772
  }
725
773
  ```
726
774
 
727
- **Why it's wrong:** When a user hits an API via Swagger with a valid JWT but no tenant context (missing `X-Tenant-Slug` header), `TenantId` is null. The `!.Value` pattern produces an opaque `500 Internal Server Error` instead of a clear `401 Unauthorized` with an actionable message. The `?? throw new UnauthorizedAccessException(...)` pattern is caught by `GlobalExceptionHandlerMiddleware` and returned as a proper 401 response.
775
+ **Why `!.Value` is wrong:** When a user hits an API via Swagger with a valid JWT but no tenant context (missing `X-Tenant-Slug` header), `TenantId` is null. The `!.Value` pattern produces an opaque `500 Internal Server Error` instead of a clear `400 Bad Request` with an actionable message.
776
+
777
+ **Why `UnauthorizedAccessException` is wrong:** A missing tenant is NOT an auth failure — the JWT is valid, `[Authorize]` passed. Using `UnauthorizedAccessException` returns 401, which triggers the frontend interceptor to clear the token and redirect to login. Use `TenantContextRequiredException` instead (returns 400, does not clear the token).
@@ -326,7 +326,7 @@ export function EntityDetailPage() {
326
326
  }, [activeTab]);
327
327
 
328
328
  // Edit button navigates to /:id/edit route (NEVER opens a modal)
329
- const handleEdit = () => navigate(`/${basePath}/${entityId}/edit`);
329
+ const handleEdit = () => navigate(`edit`);
330
330
 
331
331
  // ... loading/error/content pattern
332
332
  }
@@ -341,6 +341,11 @@ export function EntityDetailPage() {
341
341
 
342
342
  ### Route Convention
343
343
 
344
+ > **CRITICAL:** Route paths MUST use **kebab-case** matching the navigation seed data (which uses `ToKebabCase()`).
345
+ > - Single word: `employees` (no change needed)
346
+ > - Multi-word: `human-resources`, `time-management` (kebab-case with hyphens)
347
+ > - **FORBIDDEN:** `humanresources`, `timemanagement` (concatenated words without hyphens)
348
+
344
349
  | Action | Route pattern | Page component | File location |
345
350
  |--------|--------------|----------------|---------------|
346
351
  | Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{Context}/{App}/{Module}/EntityCreatePage.tsx` |
@@ -498,6 +503,41 @@ const EntityEditPage = lazy(() =>
498
503
  { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
499
504
  ]
500
505
  }
506
+
507
+ // Section-level routes — children of the module route (when module has sections)
508
+ //
509
+ // > **IMPORTANT:** The `list` and `detail` sections do NOT generate additional route entries.
510
+ // > They are already covered by the module's `index: true` (list) and `path: ':id'` (detail) routes above.
511
+ // > Only sections like `dashboard`, `approve`, `import`, etc. generate the section-kebab child routes below.
512
+ // > FORBIDDEN: `path: 'list'`, `path: 'detail'` — these would create unreachable duplicate routes.
513
+ //
514
+ {
515
+ path: '{module-kebab}',
516
+ children: [
517
+ { index: true, element: <Suspense fallback={<PageLoader />}><{Module}Page /></Suspense> },
518
+ { path: 'create', element: <Suspense fallback={<PageLoader />}><Create{Module}Page /></Suspense> },
519
+ { path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}DetailPage /></Suspense> },
520
+ { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Module}Page /></Suspense> },
521
+ // Section routes as children of module:
522
+ // IMPORTANT: "list" and "detail" are NOT separate path segments.
523
+ // - "list" section = already handled by the module's index route above (index: true)
524
+ // - "detail" section = already handled by the module's :id route above (path: ':id')
525
+ // - Only OTHER sections (dashboard, approve, import, etc.) add path segments:
526
+ { path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
527
+ { path: '{section-kebab}/create', element: <Suspense fallback={<PageLoader />}><Create{Section}Page /></Suspense> },
528
+ { path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}DetailPage /></Suspense> },
529
+ { path: '{section-kebab}/:id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Section}Page /></Suspense> },
530
+ ]
531
+ }
532
+
533
+ // PermissionGuard for section-level routes
534
+ element: (
535
+ <Suspense fallback={<PageLoader />}>
536
+ <PermissionGuard permissions={ROUTES['business.app.module.section'].permissions}>
537
+ <SectionPage />
538
+ </PermissionGuard>
539
+ </Suspense>
540
+ )
501
541
  ```
502
542
 
503
543
  ### Rules