@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.
- package/README.md +796 -6
- package/dist/commands/server/new.js +1 -1
- package/dist/commands/server/scaffold.js +20 -9
- package/dist/lib/apsorc-parser.d.ts +3 -0
- package/dist/lib/apsorc-parser.js +11 -6
- package/dist/lib/controller.js +3 -1
- package/dist/lib/dto.js +9 -9
- package/dist/lib/entity.js +6 -5
- package/dist/lib/guards.d.ts +47 -0
- package/dist/lib/guards.js +176 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +5 -1
- package/dist/lib/module.js +3 -1
- package/dist/lib/templates/guards/auth.guard.eta +228 -0
- package/dist/lib/templates/guards/guards.module.eta +114 -0
- package/dist/lib/templates/guards/index.eta +22 -0
- package/dist/lib/templates/guards/scope.guard.eta +327 -0
- package/dist/lib/types/auth.d.ts +203 -0
- package/dist/lib/types/auth.js +60 -0
- package/dist/lib/types/entity.d.ts +36 -1
- package/dist/lib/types/index.d.ts +2 -1
- package/dist/lib/types/index.js +8 -0
- package/dist/lib/types/relationship.d.ts +1 -1
- package/dist/lib/utils/casing.js +5 -5
- package/dist/lib/utils/field.js +2 -1
- package/dist/lib/utils/file-system.js +1 -1
- package/dist/lib/utils/relationships/parse-v1.js +2 -1
- package/dist/lib/utils/relationships/parse.js +117 -32
- package/dist/lib/utils/relationships/parse.spec.js +168 -141
- package/dist/test-nested-relationships.js +10 -10
- package/dist/tests/templates/entities/types/array-template.spec.js +45 -45
- package/dist/tests/templates/entities/types/bigint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/boolean-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/bytea-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/char-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/date-template.spec.js +29 -29
- package/dist/tests/templates/entities/types/decimal-field-validation.spec.js +46 -46
- package/dist/tests/templates/entities/types/decimal-template.spec.js +46 -46
- package/dist/tests/templates/entities/types/double-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/enum-template.spec.js +42 -42
- package/dist/tests/templates/entities/types/float-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/geometry-template.spec.js +31 -31
- package/dist/tests/templates/entities/types/inet-template.spec.js +36 -36
- package/dist/tests/templates/entities/types/int4range-template.spec.js +45 -33
- package/dist/tests/templates/entities/types/integer-template.spec.js +40 -40
- package/dist/tests/templates/entities/types/interval-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/json-plain-template.spec.js +27 -27
- package/dist/tests/templates/entities/types/json-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/jsonb-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/money-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/numeric-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/point-template.spec.js +37 -31
- package/dist/tests/templates/entities/types/polygon-template.spec.js +43 -31
- package/dist/tests/templates/entities/types/real-template.spec.js +34 -34
- package/dist/tests/templates/entities/types/serial-template.spec.js +62 -62
- package/dist/tests/templates/entities/types/smallint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/string-template.spec.js +59 -59
- package/dist/tests/templates/entities/types/text-template.spec.js +37 -37
- package/dist/tests/templates/entities/types/time-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/timestamp-template.spec.js +39 -39
- package/dist/tests/templates/entities/types/timetz-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/tsvector-template.spec.js +28 -28
- package/dist/tests/templates/entities/types/uuid-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/varchar-template.spec.js +59 -56
- package/dist/tests/templates/entities/types/xml-template.spec.js +28 -28
- package/dist/tests/templates/entities/utils/template-test-utils.d.ts +1 -1
- package/dist/tests/templates/entities/utils/template-test-utils.js +3 -3
- package/dist/tests/templates/entities/uuid-entity-template.spec.js +138 -138
- package/dist/tests/utils/relationship-deduplication.spec.js +11 -11
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
1
|
# Apso CLI
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
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
|
-
- [
|
|
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.
|