@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.
- package/dist/index.js +6 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/templates/project/api.ts.template +4 -2
- package/templates/project/appsettings.json.template +1 -1
- package/templates/skills/apex/_shared.md +13 -0
- package/templates/skills/apex/references/post-checks.md +228 -6
- package/templates/skills/apex/references/smartstack-api.md +67 -17
- package/templates/skills/apex/references/smartstack-frontend.md +41 -1
- package/templates/skills/apex/references/smartstack-layers.md +40 -10
- package/templates/skills/apex/steps/step-02-plan.md +16 -11
- package/templates/skills/apex/steps/step-03-execute.md +6 -0
- package/templates/skills/apex/steps/step-04-examine.md +4 -2
- package/templates/skills/application/references/frontend-verification.md +26 -1
- package/templates/skills/application/steps/step-03-roles.md +1 -1
- package/templates/skills/application/steps/step-05-frontend.md +24 -8
- package/templates/skills/application/templates-frontend.md +41 -22
- package/templates/skills/application/templates-seed.md +53 -16
- package/templates/skills/business-analyse/SKILL.md +4 -2
- package/templates/skills/business-analyse/_shared.md +17 -4
- package/templates/skills/business-analyse/react/schema.md +1 -1
- package/templates/skills/business-analyse/references/agent-module-prompt.md +11 -9
- package/templates/skills/business-analyse/references/consolidation-structural-checks.md +4 -3
- package/templates/skills/business-analyse/references/deploy-modes.md +1 -1
- package/templates/skills/business-analyse/references/handoff-file-templates.md +4 -4
- package/templates/skills/business-analyse/references/robustness-checks.md +12 -9
- package/templates/skills/business-analyse/references/spec-auto-inference.md +3 -3
- package/templates/skills/business-analyse/references/ui-resource-cards.md +3 -3
- package/templates/skills/business-analyse/references/validation-checklist.md +21 -3
- package/templates/skills/business-analyse/schemas/sections/specification-schema.json +33 -5
- package/templates/skills/business-analyse/steps/step-03b-ui.md +2 -2
- package/templates/skills/business-analyse/steps/step-03c-compile.md +17 -9
- package/templates/skills/business-analyse/steps/step-03d-validate.md +1 -1
- package/templates/skills/business-analyse/steps/step-04b-analyze.md +5 -3
- package/templates/skills/business-analyse/steps/step-05a-handoff.md +23 -15
- package/templates/skills/business-analyse/templates/tpl-handoff.md +10 -8
- package/templates/skills/business-analyse/templates/tpl-progress.md +7 -6
- package/templates/skills/ralph-loop/references/category-rules.md +50 -6
- package/templates/skills/ralph-loop/references/compact-loop.md +16 -1
- package/templates/skills/ralph-loop/references/core-seed-data.md +158 -38
- package/templates/skills/ralph-loop/references/task-transform-legacy.md +3 -3
- 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.
|
|
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
|
-
//
|
|
11
|
+
// import { api } from './api';
|
|
12
|
+
// export const getEmployees = () => api.get('/api/business/human-resources/employees');
|
|
@@ -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 "
|
|
54
|
-
echo "
|
|
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
|
|
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
|
|
486
|
+
echo " ?? throw new TenantContextRequiredException();"
|
|
450
487
|
echo ""
|
|
451
|
-
echo "This produces a clean
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
324
|
+
?? throw new TenantContextRequiredException();
|
|
325
325
|
```
|
|
326
|
-
This converts a null TenantId into a clean
|
|
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
|
|
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/
|
|
416
|
-
| Resource | `/{context}/{app-kebab}/{module-kebab}/{section-kebab}/{resource-kebab}` | `/business/human-resources/employees/
|
|
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, "(
|
|
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.
|
|
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
|
|
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
|
|
763
|
+
**CORRECT — Clean 400 via GlobalExceptionHandlerMiddleware:**
|
|
716
764
|
```csharp
|
|
717
|
-
// CORRECT: Throws
|
|
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
|
|
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
|
|
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(
|
|
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
|