@apso/cli 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +796 -6
  2. package/dist/commands/server/new.js +1 -1
  3. package/dist/commands/server/scaffold.js +20 -9
  4. package/dist/lib/apsorc-parser.d.ts +3 -0
  5. package/dist/lib/apsorc-parser.js +11 -6
  6. package/dist/lib/controller.js +3 -1
  7. package/dist/lib/dto.js +9 -9
  8. package/dist/lib/entity.js +6 -5
  9. package/dist/lib/guards.d.ts +47 -0
  10. package/dist/lib/guards.js +176 -0
  11. package/dist/lib/index.d.ts +1 -0
  12. package/dist/lib/index.js +5 -1
  13. package/dist/lib/module.js +3 -1
  14. package/dist/lib/templates/guards/auth.guard.eta +228 -0
  15. package/dist/lib/templates/guards/guards.module.eta +114 -0
  16. package/dist/lib/templates/guards/index.eta +22 -0
  17. package/dist/lib/templates/guards/scope.guard.eta +327 -0
  18. package/dist/lib/types/auth.d.ts +203 -0
  19. package/dist/lib/types/auth.js +60 -0
  20. package/dist/lib/types/entity.d.ts +36 -1
  21. package/dist/lib/types/index.d.ts +2 -1
  22. package/dist/lib/types/index.js +8 -0
  23. package/dist/lib/types/relationship.d.ts +1 -1
  24. package/dist/lib/utils/casing.js +5 -5
  25. package/dist/lib/utils/field.js +2 -1
  26. package/dist/lib/utils/file-system.js +1 -1
  27. package/dist/lib/utils/relationships/parse-v1.js +2 -1
  28. package/dist/lib/utils/relationships/parse.js +117 -32
  29. package/dist/lib/utils/relationships/parse.spec.js +168 -141
  30. package/dist/test-nested-relationships.js +10 -10
  31. package/dist/tests/templates/entities/types/array-template.spec.js +45 -45
  32. package/dist/tests/templates/entities/types/bigint-template.spec.js +43 -43
  33. package/dist/tests/templates/entities/types/boolean-template.spec.js +44 -44
  34. package/dist/tests/templates/entities/types/bytea-template.spec.js +35 -35
  35. package/dist/tests/templates/entities/types/char-template.spec.js +35 -35
  36. package/dist/tests/templates/entities/types/date-template.spec.js +29 -29
  37. package/dist/tests/templates/entities/types/decimal-field-validation.spec.js +46 -46
  38. package/dist/tests/templates/entities/types/decimal-template.spec.js +46 -46
  39. package/dist/tests/templates/entities/types/double-template.spec.js +37 -34
  40. package/dist/tests/templates/entities/types/enum-template.spec.js +42 -42
  41. package/dist/tests/templates/entities/types/float-template.spec.js +37 -34
  42. package/dist/tests/templates/entities/types/geometry-template.spec.js +31 -31
  43. package/dist/tests/templates/entities/types/inet-template.spec.js +36 -36
  44. package/dist/tests/templates/entities/types/int4range-template.spec.js +45 -33
  45. package/dist/tests/templates/entities/types/integer-template.spec.js +40 -40
  46. package/dist/tests/templates/entities/types/interval-template.spec.js +35 -35
  47. package/dist/tests/templates/entities/types/json-plain-template.spec.js +27 -27
  48. package/dist/tests/templates/entities/types/json-template.spec.js +19 -19
  49. package/dist/tests/templates/entities/types/jsonb-template.spec.js +19 -19
  50. package/dist/tests/templates/entities/types/money-template.spec.js +35 -35
  51. package/dist/tests/templates/entities/types/numeric-template.spec.js +44 -44
  52. package/dist/tests/templates/entities/types/point-template.spec.js +37 -31
  53. package/dist/tests/templates/entities/types/polygon-template.spec.js +43 -31
  54. package/dist/tests/templates/entities/types/real-template.spec.js +34 -34
  55. package/dist/tests/templates/entities/types/serial-template.spec.js +62 -62
  56. package/dist/tests/templates/entities/types/smallint-template.spec.js +43 -43
  57. package/dist/tests/templates/entities/types/string-template.spec.js +59 -59
  58. package/dist/tests/templates/entities/types/text-template.spec.js +37 -37
  59. package/dist/tests/templates/entities/types/time-template.spec.js +35 -35
  60. package/dist/tests/templates/entities/types/timestamp-template.spec.js +39 -39
  61. package/dist/tests/templates/entities/types/timetz-template.spec.js +35 -35
  62. package/dist/tests/templates/entities/types/tsvector-template.spec.js +28 -28
  63. package/dist/tests/templates/entities/types/uuid-template.spec.js +43 -43
  64. package/dist/tests/templates/entities/types/varchar-template.spec.js +59 -56
  65. package/dist/tests/templates/entities/types/xml-template.spec.js +28 -28
  66. package/dist/tests/templates/entities/utils/template-test-utils.d.ts +1 -1
  67. package/dist/tests/templates/entities/utils/template-test-utils.js +3 -3
  68. package/dist/tests/templates/entities/uuid-entity-template.spec.js +138 -138
  69. package/dist/tests/utils/relationship-deduplication.spec.js +11 -11
  70. package/oclif.manifest.json +1 -1
  71. package/package.json +1 -1
package/README.md CHANGED
@@ -1,14 +1,48 @@
1
1
  # Apso CLI
2
2
 
3
- - [Apso CLI](#apso-cli)
4
- - [Prerequisites](#prerequisites)
3
+ Generate production-ready NestJS backends from schema definitions.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # 1. Install CLI
9
+ npm install -g @apso/apso-cli
10
+
11
+ # 2. Create new project
12
+ apso server new --name myapp
13
+
14
+ # 3. Edit .apsorc to define your schema (see examples below)
15
+
16
+ # 4. Generate code
17
+ apso server scaffold
18
+
19
+ # 5. Start database & provision schema
20
+ npm run compose && npm run provision
21
+
22
+ # 6. Run development server
23
+ npm run start:dev
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Table of Contents
29
+
30
+ - [Quick Start](#quick-start)
5
31
  - [Usage](#usage)
32
+ - [Important: Never Modify Autogen Files](#-important-never-add-custom-code-to-autogen-files)
33
+ - [Common First-Time Mistakes](#️-common-first-time-mistakes)
6
34
  - [Local Development](#local-development)
7
35
  - [Populating an .apsorc File](#populating-an-apsorc-file)
8
36
  - [Auto-Generated Code Reference](#auto-generated-code-reference)
9
- - [Debugging](##debugging)
37
+ - [Relationships](#relationships)
38
+ - [Authentication (Bring Your Own Auth)](#authentication-bring-your-own-auth)
39
+ - [Data Scoping (Multi-Tenant Isolation)](#data-scoping-multi-tenant-isolation)
40
+ - [Authentication + Scoping: Working Together](#authentication--scoping-working-together)
41
+ - [Schema Reference](#schema-reference)
42
+ - [Debugging](#debugging)
10
43
  - [Commands](#commands)
11
44
 
45
+ ---
12
46
 
13
47
  # Usage
14
48
 
@@ -139,13 +173,47 @@ export class LambdaDeploymentController {
139
173
 
140
174
  ---
141
175
 
142
- **Summary:**
143
- > Always put your custom code in `src/extensions/[[EntityName]]/`.
144
- > Never modify files in `src/autogen/`.
176
+ **Summary:**
177
+ > Always put your custom code in `src/extensions/[[EntityName]]/`.
178
+ > Never modify files in `src/autogen/`.
145
179
  > This ensures your work is safe and your project remains maintainable as you evolve your data model with Apso CLI.
146
180
 
147
181
  ---
148
182
 
183
+ ## ⚠️ Common First-Time Mistakes
184
+
185
+ ### 1. Modifying `autogen/` Files
186
+ **Problem:** Changes get overwritten on next scaffold
187
+ **Solution:** Always use `extensions/` directory for custom code
188
+
189
+ ### 2. Defining Both Sides of Relationships
190
+ **Problem:** Duplicate properties and TypeScript errors
191
+ **Solution:** Define relationships **once** - Apso auto-generates the inverse side
192
+
193
+ Example:
194
+ ```json
195
+ // ❌ WRONG - Creates conflicts
196
+ { "from": "User", "to": "Workspace", "type": "OneToMany" }
197
+ { "from": "Workspace", "to": "User", "type": "ManyToOne" }
198
+
199
+ // ✅ CORRECT - Define one side only
200
+ { "from": "Workspace", "to": "User", "type": "ManyToOne", "to_name": "owner" }
201
+ ```
202
+
203
+ ### 3. Skipping Database Provisioning
204
+ **Problem:** Server fails to start with missing table errors
205
+ **Solution:** Always run `npm run provision` after scaffold
206
+
207
+ ### 4. Wrong .apsorc Version
208
+ **Problem:** Schema doesn't generate correctly
209
+ **Solution:** Ensure `"version": 2` at top of .apsorc
210
+
211
+ ### 5. Forgetting to Start Docker
212
+ **Problem:** Database connection refused errors
213
+ **Solution:** Run `npm run compose` before starting server
214
+
215
+ ---
216
+
149
217
  # Local Development
150
218
 
151
219
  Clone apso-cli on your machine. Navigate to the repo in your code editor and run the below commands
@@ -680,6 +748,728 @@ Refer to the [example v2 file](#example-apsorc-v2-file) for usage.
680
748
  > - Avoid deep nesting and duplicate definitions.
681
749
  > - Always inspect generated code and test your build after scaffolding.
682
750
 
751
+ ## Authentication (Bring Your Own Auth)
752
+
753
+ Apso provides flexible, provider-agnostic authentication that generates NestJS guards from your `.apsorc` configuration. Unlike monolithic platforms that force vendor lock-in through proprietary auth systems, Apso embraces **code ownership** - you choose your auth provider, and you own the generated code.
754
+
755
+ ### Philosophy: Why Bring Your Own Auth Matters
756
+
757
+ Traditional BaaS platforms like Supabase and Firebase provide authentication as a core feature, but this creates dependency:
758
+ - Your user data lives in their systems
759
+ - Migrating away requires rewriting auth logic
760
+ - You're bound to their pricing, features, and roadmap
761
+
762
+ **Apso takes a different approach:**
763
+ - **Provider flexibility** - Use Better Auth, Auth0, Cognito, Clerk, or custom solutions
764
+ - **Code ownership** - Generated guards are standard NestJS code you can inspect, modify, and extend
765
+ - **Zero lock-in** - Switch providers by changing configuration, not rewriting code
766
+ - **Normalized interface** - All providers produce the same `AuthContext` consumed by scoping and RBAC
767
+
768
+ This philosophy ensures your authentication layer is **timeless** - it grows with your needs and migrates with your stack.
769
+
770
+ ### Supported Authentication Providers
771
+
772
+ | Provider | Type | Best For |
773
+ |----------|------|----------|
774
+ | `better-auth` | Database Sessions | Self-hosted apps, maximum control |
775
+ | `custom-db-session` | Database Sessions | Existing session tables, custom flows |
776
+ | `auth0` | JWT | Enterprise SSO, social login |
777
+ | `clerk` | JWT | Modern SaaS with prebuilt UI |
778
+ | `cognito` | JWT | AWS ecosystem integration |
779
+ | `api-key` | API Keys | Service-to-service auth, public APIs |
780
+
781
+ ### Basic Configuration
782
+
783
+ Add an `auth` block to your `.apsorc`:
784
+
785
+ ```json
786
+ {
787
+ "version": 2,
788
+ "auth": {
789
+ "provider": "better-auth"
790
+ },
791
+ "entities": [...]
792
+ }
793
+ ```
794
+
795
+ Run `apso server scaffold` to generate the auth guard.
796
+
797
+ ### Provider-Specific Configuration
798
+
799
+ #### Better Auth / Custom DB Sessions
800
+
801
+ For database-backed session authentication:
802
+
803
+ ```json
804
+ {
805
+ "auth": {
806
+ "provider": "better-auth",
807
+ "sessionEntity": "session",
808
+ "userEntity": "User",
809
+ "accountUserEntity": "AccountUser",
810
+ "cookiePrefix": "myapp",
811
+ "organizationField": "organizationId",
812
+ "roleField": "role"
813
+ }
814
+ }
815
+ ```
816
+
817
+ | Option | Default | Description |
818
+ |--------|---------|-------------|
819
+ | `sessionEntity` | `"session"` | Entity storing session tokens |
820
+ | `userEntity` | `"User"` | Entity for user accounts |
821
+ | `accountUserEntity` | `"AccountUser"` | Junction entity for user-org mapping |
822
+ | `cookiePrefix` | service name | Cookie name prefix (e.g., `myapp.session_token`) |
823
+ | `organizationField` | `"organizationId"` | Field on accountUserEntity for org ID |
824
+ | `roleField` | `"role"` | Field on accountUserEntity for user role |
825
+
826
+ #### JWT Providers (Auth0, Clerk, Cognito)
827
+
828
+ For JWT-based authentication:
829
+
830
+ ```json
831
+ {
832
+ "auth": {
833
+ "provider": "auth0",
834
+ "jwt": {
835
+ "issuer": "https://your-tenant.auth0.com/",
836
+ "audience": "https://your-api.example.com",
837
+ "jwksUri": "https://your-tenant.auth0.com/.well-known/jwks.json",
838
+ "algorithms": ["RS256"]
839
+ },
840
+ "claims": {
841
+ "userId": "sub",
842
+ "email": "email",
843
+ "organizationId": "org_id",
844
+ "roles": "permissions"
845
+ }
846
+ }
847
+ }
848
+ ```
849
+
850
+ | JWT Option | Default | Description |
851
+ |------------|---------|-------------|
852
+ | `issuer` | Required | JWT issuer URL |
853
+ | `audience` | Required | Expected JWT audience |
854
+ | `jwksUri` | `issuer + /.well-known/jwks.json` | JWKS endpoint for key rotation |
855
+ | `algorithms` | `["RS256"]` | Accepted signing algorithms |
856
+
857
+ | Claims Option | Default | Description |
858
+ |---------------|---------|-------------|
859
+ | `userId` | `"sub"` | Claim containing user ID |
860
+ | `email` | `"email"` | Claim containing user email |
861
+ | `organizationId` | - | Claim for org/workspace ID |
862
+ | `roles` | `"roles"` | Claim containing role array |
863
+
864
+ #### API Key Authentication
865
+
866
+ For service-to-service or public API authentication:
867
+
868
+ ```json
869
+ {
870
+ "auth": {
871
+ "provider": "api-key",
872
+ "apiKeyHeader": "x-api-key",
873
+ "apiKeyEntity": "ApiKey",
874
+ "workspaceField": "workspaceId",
875
+ "roleField": "permissions"
876
+ }
877
+ }
878
+ ```
879
+
880
+ | Option | Default | Description |
881
+ |--------|---------|-------------|
882
+ | `apiKeyHeader` | `"x-api-key"` | Header name for API key |
883
+ | `apiKeyEntity` | `"ApiKey"` | Entity storing API keys |
884
+ | `workspaceField` | - | Field on entity for workspace ID |
885
+ | `roleField` | - | Field on entity for permissions |
886
+
887
+ ### The AuthContext Interface
888
+
889
+ All auth providers produce a normalized `AuthContext` that's attached to every authenticated request:
890
+
891
+ ```typescript
892
+ interface AuthContext {
893
+ userId?: string; // The authenticated user's ID
894
+ email?: string; // User's email (if available)
895
+ workspaceId?: string; // Workspace/tenant ID
896
+ organizationId?: string; // Organization ID (alias)
897
+ roles: string[]; // User's roles/permissions
898
+ serviceId?: string; // For API key auth: the key identifier
899
+ user?: unknown; // Raw user object (provider-specific)
900
+ session?: unknown; // Raw session object (provider-specific)
901
+ }
902
+ ```
903
+
904
+ This normalization is powerful - your business logic works with the same interface regardless of whether you're using Auth0 JWTs or Better Auth sessions.
905
+
906
+ ### Generated Files
907
+
908
+ When `auth` is configured, `apso server scaffold` generates:
909
+
910
+ ```
911
+ src/
912
+ guards/
913
+ auth.guard.ts # Authentication guard implementation
914
+ scope.guard.ts # Data scoping guard (if scopeBy used)
915
+ guards.module.ts # NestJS module with providers
916
+ index.ts # Exports
917
+ ```
918
+
919
+ ### Enabling Authentication
920
+
921
+ Guards are generated but **not enabled globally by default**. Enable them based on your needs:
922
+
923
+ #### Option 1: Global Enable (Recommended for most apps)
924
+
925
+ Edit `src/guards/guards.module.ts`:
926
+
927
+ ```typescript
928
+ providers: [
929
+ AuthGuard,
930
+ ScopeGuard,
931
+ // Uncomment to enable globally:
932
+ {
933
+ provide: APP_GUARD,
934
+ useClass: AuthGuard,
935
+ },
936
+ {
937
+ provide: APP_GUARD,
938
+ useClass: ScopeGuard,
939
+ },
940
+ ],
941
+ ```
942
+
943
+ #### Option 2: Controller-Level Enable
944
+
945
+ ```typescript
946
+ import { AuthGuard } from '../guards';
947
+
948
+ @UseGuards(AuthGuard)
949
+ @Controller('projects')
950
+ export class ProjectController { }
951
+ ```
952
+
953
+ #### Option 3: Route-Level Enable
954
+
955
+ ```typescript
956
+ @UseGuards(AuthGuard)
957
+ @Get('me')
958
+ getProfile(@Req() req: AuthenticatedRequest) {
959
+ return req.auth;
960
+ }
961
+ ```
962
+
963
+ ### Decorators
964
+
965
+ ```typescript
966
+ import { Public, SkipScopeCheck } from './guards';
967
+
968
+ // Skip ALL guards (no authentication required)
969
+ @Public()
970
+ @Get('health')
971
+ healthCheck() { }
972
+
973
+ // Skip only scope checking (auth still required)
974
+ @SkipScopeCheck()
975
+ @Get('admin/stats')
976
+ adminStats() { }
977
+ ```
978
+
979
+ ### Accessing Auth Context
980
+
981
+ In controllers and services, access the authenticated context:
982
+
983
+ ```typescript
984
+ import { AuthenticatedRequest, getAuthContext, requireAuthContext } from './guards';
985
+
986
+ @Controller('projects')
987
+ export class ProjectController {
988
+ @Get()
989
+ findAll(@Req() req: AuthenticatedRequest) {
990
+ // Direct access
991
+ const userId = req.auth.userId;
992
+ const orgId = req.auth.organizationId;
993
+
994
+ // Or use helpers
995
+ const ctx = requireAuthContext(req); // Throws if not authenticated
996
+ return this.projectService.findByOrg(ctx.organizationId);
997
+ }
998
+ }
999
+ ```
1000
+
1001
+ ### Token Extraction
1002
+
1003
+ The generated auth guard extracts tokens from multiple locations (in order):
1004
+
1005
+ 1. **Authorization header**: `Bearer <token>`
1006
+ 2. **Cookies**: `{cookiePrefix}.session_token`, `better-auth.session_token`, or `session_token`
1007
+ 3. **Custom header**: `X-Session-Token`
1008
+
1009
+ This flexibility supports both browser-based apps (cookies) and API clients (headers).
1010
+
1011
+ ### Complete Example
1012
+
1013
+ ```json
1014
+ {
1015
+ "version": 2,
1016
+ "auth": {
1017
+ "provider": "better-auth",
1018
+ "sessionEntity": "session",
1019
+ "userEntity": "User",
1020
+ "accountUserEntity": "AccountUser",
1021
+ "cookiePrefix": "myapp",
1022
+ "organizationField": "organizationId",
1023
+ "roleField": "role"
1024
+ },
1025
+ "entities": [
1026
+ {
1027
+ "name": "User",
1028
+ "fields": [
1029
+ { "name": "email", "type": "text", "unique": true },
1030
+ { "name": "name", "type": "text", "nullable": true }
1031
+ ]
1032
+ },
1033
+ {
1034
+ "name": "session",
1035
+ "fields": [
1036
+ { "name": "token", "type": "text", "unique": true },
1037
+ { "name": "expiresAt", "type": "timestamp" },
1038
+ { "name": "userId", "type": "text" }
1039
+ ]
1040
+ },
1041
+ {
1042
+ "name": "Organization",
1043
+ "fields": [
1044
+ { "name": "name", "type": "text" }
1045
+ ]
1046
+ },
1047
+ {
1048
+ "name": "AccountUser",
1049
+ "fields": [
1050
+ { "name": "role", "type": "enum", "values": ["owner", "admin", "member"] }
1051
+ ]
1052
+ },
1053
+ {
1054
+ "name": "Project",
1055
+ "scopeBy": "organizationId",
1056
+ "fields": [
1057
+ { "name": "name", "type": "text" }
1058
+ ]
1059
+ }
1060
+ ],
1061
+ "relationships": [
1062
+ { "from": "AccountUser", "to": "User", "type": "ManyToOne" },
1063
+ { "from": "AccountUser", "to": "Organization", "type": "ManyToOne" },
1064
+ { "from": "Project", "to": "Organization", "type": "ManyToOne" }
1065
+ ]
1066
+ }
1067
+ ```
1068
+
1069
+ ### Migrating Between Providers
1070
+
1071
+ One of Apso's key advantages is seamless provider migration:
1072
+
1073
+ 1. Update the `auth` block in `.apsorc`
1074
+ 2. Run `apso server scaffold`
1075
+ 3. Update your frontend to use the new provider's login flow
1076
+
1077
+ Your business logic remains unchanged because it only interacts with the normalized `AuthContext`.
1078
+
1079
+ ---
1080
+
1081
+ ## Data Scoping (Multi-Tenant Isolation)
1082
+
1083
+ Apso provides application-layer data isolation that rivals PostgreSQL's Row-Level Security (RLS) - but with greater flexibility, visibility, and portability. This is Apso's answer to one of the most critical challenges in SaaS development: ensuring users only see and modify data they're authorized to access.
1084
+
1085
+ ### Philosophy: Application-Layer RLS
1086
+
1087
+ Traditional approaches to multi-tenant data isolation include:
1088
+ - **Database RLS (Supabase)** - Powerful but opaque, tied to PostgreSQL, difficult to debug
1089
+ - **Manual filtering** - Error-prone, repetitive, easy to forget on new endpoints
1090
+ - **ORM middleware** - Often complex, hard to customize
1091
+
1092
+ **Apso's approach delivers the best of all worlds:**
1093
+ - **Declarative** - Define scope once in `.apsorc`, applied everywhere
1094
+ - **Transparent** - Generated guards are standard NestJS code you can inspect and debug
1095
+ - **Portable** - Works with any database, not locked to PostgreSQL RLS
1096
+ - **Flexible** - Configure per-entity behavior: auto-injection, filtering, bypass rules
1097
+
1098
+ This design is **timeless** - your data isolation logic is explicit code, not hidden database magic.
1099
+
1100
+ ### What is scopeBy?
1101
+
1102
+ The `scopeBy` property on entities defines which field(s) determine the authorization scope for that entity. When configured, Apso generates NestJS guards that:
1103
+
1104
+ - **Auto-inject** scope values on create operations (POST requests)
1105
+ - **Auto-filter** queries by scope values on list operations (GET without ID)
1106
+ - **Verify ownership** on single-resource operations (GET/PUT/PATCH/DELETE by ID)
1107
+
1108
+ This is Apso's answer to PostgreSQL Row-Level Security (RLS), implemented at the application layer for flexibility and visibility.
1109
+
1110
+ ### Basic Example
1111
+
1112
+ ```json
1113
+ {
1114
+ "version": 2,
1115
+ "entities": [
1116
+ {
1117
+ "name": "Project",
1118
+ "scopeBy": "workspaceId",
1119
+ "fields": [
1120
+ { "name": "name", "type": "text" }
1121
+ ]
1122
+ }
1123
+ ]
1124
+ }
1125
+ ```
1126
+
1127
+ This generates a guard that ensures:
1128
+ - All Project queries filter by `workspaceId` from the request context
1129
+ - New Projects automatically get the `workspaceId` injected
1130
+ - Single Project access verifies the Project belongs to the user's workspace
1131
+
1132
+ ### scopeBy Configuration Options
1133
+
1134
+ #### Single Field Scoping
1135
+ ```json
1136
+ {
1137
+ "name": "Project",
1138
+ "scopeBy": "workspaceId"
1139
+ }
1140
+ ```
1141
+
1142
+ #### Multiple Field Scoping
1143
+ ```json
1144
+ {
1145
+ "name": "Task",
1146
+ "scopeBy": ["workspaceId", "projectId"]
1147
+ }
1148
+ ```
1149
+
1150
+ #### Nested Path Scoping
1151
+ For entities that don't have a direct scope field but inherit scope through a relationship:
1152
+ ```json
1153
+ {
1154
+ "name": "Comment",
1155
+ "scopeBy": "task.workspaceId"
1156
+ }
1157
+ ```
1158
+ This tells the guard to look up the Task relationship and verify the workspaceId through that path.
1159
+
1160
+ ### scopeOptions
1161
+
1162
+ Fine-tune scoping behavior with `scopeOptions`:
1163
+
1164
+ ```json
1165
+ {
1166
+ "name": "AuditLog",
1167
+ "scopeBy": "workspaceId",
1168
+ "scopeOptions": {
1169
+ "injectOnCreate": false,
1170
+ "enforceOn": ["find", "get"],
1171
+ "bypassRoles": ["admin", "superadmin"]
1172
+ }
1173
+ }
1174
+ ```
1175
+
1176
+ | Option | Type | Default | Description |
1177
+ |--------|------|---------|-------------|
1178
+ | `injectOnCreate` | boolean | `true` | Auto-inject scope value on POST requests |
1179
+ | `enforceOn` | string[] | `["find", "get", "create", "update", "delete"]` | Operations where scope is enforced |
1180
+ | `bypassRoles` | string[] | `[]` | Roles that skip scope checking |
1181
+
1182
+ ### Generated Files
1183
+
1184
+ When entities have `scopeBy` configured, `apso server scaffold` generates:
1185
+
1186
+ ```
1187
+ src/
1188
+ guards/
1189
+ scope.guard.ts # Main guard implementation
1190
+ guards.module.ts # NestJS module with providers
1191
+ index.ts # Exports
1192
+ ```
1193
+
1194
+ ### Enabling Guards
1195
+
1196
+ Guards are generated but **not enabled globally by default** (for backward compatibility). To enable:
1197
+
1198
+ #### Option 1: Global Enable (Recommended)
1199
+ Uncomment the APP_GUARD provider in `src/guards/guards.module.ts`:
1200
+
1201
+ ```typescript
1202
+ providers: [
1203
+ ScopeGuard,
1204
+ // Uncomment to enable globally:
1205
+ {
1206
+ provide: APP_GUARD,
1207
+ useClass: ScopeGuard,
1208
+ },
1209
+ ],
1210
+ ```
1211
+
1212
+ #### Option 2: Per-Controller Enable
1213
+ Apply to specific controllers:
1214
+
1215
+ ```typescript
1216
+ import { ScopeGuard } from '../guards';
1217
+
1218
+ @UseGuards(ScopeGuard)
1219
+ @Controller('projects')
1220
+ export class ProjectController { }
1221
+ ```
1222
+
1223
+ #### Option 3: Per-Route Enable
1224
+ Apply to specific routes:
1225
+
1226
+ ```typescript
1227
+ @UseGuards(ScopeGuard)
1228
+ @Get(':id')
1229
+ findOne(@Param('id') id: string) { }
1230
+ ```
1231
+
1232
+ ### Decorators
1233
+
1234
+ The generated guard supports these decorators:
1235
+
1236
+ ```typescript
1237
+ import { Public, SkipScopeCheck } from './guards';
1238
+
1239
+ @Public() // Skip ALL guards for this route
1240
+ @Get('public-endpoint')
1241
+ publicRoute() { }
1242
+
1243
+ @SkipScopeCheck() // Skip only scope checking (other guards still run)
1244
+ @Get('admin-dashboard')
1245
+ adminRoute() { }
1246
+ ```
1247
+
1248
+ ### Request Context Integration
1249
+
1250
+ The guard expects scope values in the request object. Set these in your authentication middleware:
1251
+
1252
+ ```typescript
1253
+ // In your auth middleware
1254
+ request.workspaceId = user.currentWorkspaceId;
1255
+ request.user = { roles: ['user'] };
1256
+ ```
1257
+
1258
+ ### Complete Example
1259
+
1260
+ ```json
1261
+ {
1262
+ "version": 2,
1263
+ "entities": [
1264
+ {
1265
+ "name": "Workspace",
1266
+ "fields": [{ "name": "name", "type": "text" }]
1267
+ },
1268
+ {
1269
+ "name": "Project",
1270
+ "scopeBy": "workspaceId",
1271
+ "fields": [{ "name": "name", "type": "text" }]
1272
+ },
1273
+ {
1274
+ "name": "Task",
1275
+ "scopeBy": ["workspaceId", "projectId"],
1276
+ "fields": [{ "name": "title", "type": "text" }]
1277
+ },
1278
+ {
1279
+ "name": "Comment",
1280
+ "scopeBy": "task.workspaceId",
1281
+ "scopeOptions": {
1282
+ "enforceOn": ["find", "get", "create", "delete"]
1283
+ },
1284
+ "fields": [{ "name": "text", "type": "text" }]
1285
+ }
1286
+ ],
1287
+ "relationships": [
1288
+ { "from": "Project", "to": "Workspace", "type": "ManyToOne" },
1289
+ { "from": "Task", "to": "Project", "type": "ManyToOne" },
1290
+ { "from": "Comment", "to": "Task", "type": "ManyToOne" }
1291
+ ]
1292
+ }
1293
+ ```
1294
+
1295
+ ### Scoping vs Authorization
1296
+
1297
+ **Scoping** (what `scopeBy` provides):
1298
+ - Answers: "Which rows can this user see/modify?"
1299
+ - Data isolation based on tenant/workspace membership
1300
+ - Automatic filtering and injection
1301
+
1302
+ **Authorization** (separate concern, not covered by `scopeBy`):
1303
+ - Answers: "Can this user perform this action?"
1304
+ - Role-based access control (RBAC)
1305
+ - Permission checking (create, read, update, delete)
1306
+
1307
+ These are intentionally separate. Use `scopeBy` for data isolation, and implement authorization guards separately for permission checking.
1308
+
1309
+ ---
1310
+
1311
+ ## Authentication + Scoping: Working Together
1312
+
1313
+ Auth and Scoping are designed to work together seamlessly. This combined system delivers enterprise-grade security with minimal configuration.
1314
+
1315
+ ### How They Connect
1316
+
1317
+ When both `auth` and `scopeBy` are configured:
1318
+
1319
+ 1. **AuthGuard runs first** - Validates the session/token and populates `request.auth`
1320
+ 2. **ScopeGuard runs second** - Reads `organizationId`/`workspaceId` from `request.auth` and enforces isolation
1321
+
1322
+ The `AuthContext` automatically provides the scope values that `scopeBy` needs:
1323
+
1324
+ ```typescript
1325
+ // AuthGuard sets this on every authenticated request:
1326
+ request.auth = {
1327
+ userId: "user_123",
1328
+ organizationId: "org_456", // <-- ScopeGuard uses this
1329
+ workspaceId: "org_456", // <-- Or this (alias)
1330
+ roles: ["admin"],
1331
+ // ...
1332
+ }
1333
+
1334
+ // ScopeGuard then uses organizationId to:
1335
+ // - Filter GET /projects -> only org_456's projects
1336
+ // - Inject on POST /projects -> auto-set organizationId
1337
+ // - Verify on GET /projects/:id -> ensure it belongs to org_456
1338
+ ```
1339
+
1340
+ ### Complete Multi-Tenant Example
1341
+
1342
+ ```json
1343
+ {
1344
+ "version": 2,
1345
+ "auth": {
1346
+ "provider": "better-auth",
1347
+ "sessionEntity": "session",
1348
+ "userEntity": "User",
1349
+ "accountUserEntity": "AccountUser",
1350
+ "organizationField": "organizationId"
1351
+ },
1352
+ "entities": [
1353
+ {
1354
+ "name": "User",
1355
+ "fields": [
1356
+ { "name": "email", "type": "text", "unique": true },
1357
+ { "name": "name", "type": "text", "nullable": true }
1358
+ ]
1359
+ },
1360
+ {
1361
+ "name": "session",
1362
+ "fields": [
1363
+ { "name": "token", "type": "text", "unique": true },
1364
+ { "name": "expiresAt", "type": "timestamp" },
1365
+ { "name": "userId", "type": "text" }
1366
+ ]
1367
+ },
1368
+ {
1369
+ "name": "Organization",
1370
+ "fields": [
1371
+ { "name": "name", "type": "text" },
1372
+ { "name": "plan", "type": "enum", "values": ["free", "pro", "enterprise"] }
1373
+ ]
1374
+ },
1375
+ {
1376
+ "name": "AccountUser",
1377
+ "fields": [
1378
+ { "name": "role", "type": "enum", "values": ["owner", "admin", "member"] }
1379
+ ]
1380
+ },
1381
+ {
1382
+ "name": "Project",
1383
+ "scopeBy": "organizationId",
1384
+ "fields": [
1385
+ { "name": "name", "type": "text" },
1386
+ { "name": "status", "type": "enum", "values": ["active", "archived"] }
1387
+ ]
1388
+ },
1389
+ {
1390
+ "name": "Task",
1391
+ "scopeBy": ["organizationId", "projectId"],
1392
+ "fields": [
1393
+ { "name": "title", "type": "text" },
1394
+ { "name": "completed", "type": "boolean", "default": false }
1395
+ ]
1396
+ },
1397
+ {
1398
+ "name": "AuditLog",
1399
+ "scopeBy": "organizationId",
1400
+ "scopeOptions": {
1401
+ "injectOnCreate": true,
1402
+ "enforceOn": ["find", "get"],
1403
+ "bypassRoles": ["superadmin"]
1404
+ },
1405
+ "fields": [
1406
+ { "name": "action", "type": "text" },
1407
+ { "name": "details", "type": "json" }
1408
+ ]
1409
+ }
1410
+ ],
1411
+ "relationships": [
1412
+ { "from": "AccountUser", "to": "User", "type": "ManyToOne" },
1413
+ { "from": "AccountUser", "to": "Organization", "type": "ManyToOne" },
1414
+ { "from": "Project", "to": "Organization", "type": "ManyToOne" },
1415
+ { "from": "Task", "to": "Project", "type": "ManyToOne" },
1416
+ { "from": "Task", "to": "Organization", "type": "ManyToOne" },
1417
+ { "from": "AuditLog", "to": "Organization", "type": "ManyToOne" }
1418
+ ]
1419
+ }
1420
+ ```
1421
+
1422
+ ### Guard Execution Order
1423
+
1424
+ Enable both guards globally for automatic protection:
1425
+
1426
+ ```typescript
1427
+ // src/guards/guards.module.ts
1428
+ providers: [
1429
+ AuthGuard,
1430
+ ScopeGuard,
1431
+ {
1432
+ provide: APP_GUARD,
1433
+ useClass: AuthGuard, // Runs first
1434
+ },
1435
+ {
1436
+ provide: APP_GUARD,
1437
+ useClass: ScopeGuard, // Runs second
1438
+ },
1439
+ ],
1440
+ ```
1441
+
1442
+ ### The Apso Security Stack
1443
+
1444
+ | Layer | Guard | Question Answered | Configuration |
1445
+ |-------|-------|-------------------|---------------|
1446
+ | 1. Identity | AuthGuard | "Who is this user?" | `auth` in `.apsorc` |
1447
+ | 2. Isolation | ScopeGuard | "Which data can they see?" | `scopeBy` on entities |
1448
+ | 3. Authorization | (Your implementation) | "What actions can they take?" | Custom RBAC guard |
1449
+
1450
+ Apso handles layers 1 and 2 automatically. Layer 3 (fine-grained permissions like "can edit this specific resource") is left to your business logic since it varies widely between applications.
1451
+
1452
+ ### Why This Matters: The Supabase Comparison
1453
+
1454
+ | Feature | Supabase | Apso |
1455
+ |---------|----------|------|
1456
+ | **Auth** | Built-in, proprietary | Bring your own, code you own |
1457
+ | **Data Isolation** | PostgreSQL RLS (opaque) | Application-layer guards (transparent) |
1458
+ | **Portability** | Locked to Supabase | Works with any database |
1459
+ | **Debugging** | Database logs, hard to trace | Standard NestJS code, full visibility |
1460
+ | **Customization** | Limited to RLS policies | Unlimited - it's your code |
1461
+ | **Migration Path** | Rewrite required | Change config, regenerate |
1462
+
1463
+ Apso delivers equivalent functionality to Supabase's auth + RLS combo, but with:
1464
+ - **Full code ownership** - No vendor lock-in
1465
+ - **Provider flexibility** - Auth0, Clerk, Cognito, or self-hosted
1466
+ - **Database freedom** - PostgreSQL, MySQL, MongoDB, or any TypeORM-supported database
1467
+ - **Complete transparency** - Debug with standard tools, not vendor-specific dashboards
1468
+
1469
+ This architecture is **timeless** - it grows with your needs, migrates with your stack, and remains fully under your control.
1470
+
1471
+ ---
1472
+
683
1473
  ## Schema Reference
684
1474
 
685
1475
  The `.apsorc` file must conform to the [APSO Configuration Schema](./apsorc.schema.json). This schema defines all valid properties, types, and constraints for your configuration file.