spiderly 19.8.4 → 19.8.6
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/agent/docs/angular-customization/SKILL.md +389 -0
- package/agent/docs/angular-customization/references/controls.generated.md +23 -0
- package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
- package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
- package/agent/docs/angular-customization/references/validators.generated.md +13 -0
- package/agent/docs/authorization/SKILL.md +385 -0
- package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
- package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
- package/agent/docs/backend-hooks/SKILL.md +231 -0
- package/agent/docs/backend-localization/SKILL.md +170 -0
- package/agent/docs/backend-testing/SKILL.md +65 -0
- package/agent/docs/custom-endpoints/SKILL.md +409 -0
- package/agent/docs/e2e-testing/SKILL.md +139 -0
- package/agent/docs/entity-design/SKILL.md +346 -0
- package/agent/docs/entity-design/references/attributes.generated.md +53 -0
- package/agent/docs/file-storage/SKILL.md +262 -0
- package/agent/docs/filtering-patterns/SKILL.md +127 -0
- package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
- package/agent/docs/frontend-localization/SKILL.md +120 -0
- package/agent/docs/mapper-customization/SKILL.md +105 -0
- package/agent/manifest.json +34 -0
- package/agent/skills/add-entity/SKILL.md +158 -0
- package/agent/skills/deployment/SKILL.md +551 -0
- package/agent/skills/ef-migrations/SKILL.md +49 -0
- package/agent/skills/report-gap/SKILL.md +110 -0
- package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
- package/agent/skills/spiderly-upgrade/SKILL.md +166 -0
- package/agent/skills/verify-ui/SKILL.md +148 -0
- package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
- package/fesm2022/spiderly.mjs +11 -6
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
- package/package.json +1 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: authorization
|
|
3
|
+
description: Set up permission-based authorization in Spiderly. Use when implementing user/role/permission entities, seeding permissions, using DoNotAuthorize or AuthGuard attributes, checking permissions in custom code, configuring frontend auth guards, or setting up Google OAuth.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authorization
|
|
7
|
+
|
|
8
|
+
## Entity Interfaces
|
|
9
|
+
|
|
10
|
+
Implement these on your User, Role, and Permission entities:
|
|
11
|
+
|
|
12
|
+
### ISecurityPrincipal
|
|
13
|
+
|
|
14
|
+
The authorization root — anything that can hold roles and be permission-checked (a human `User`, a
|
|
15
|
+
machine/service account, an AI agent). Authorization resolves permissions against this contract, not a
|
|
16
|
+
concrete user type, so an app may have **one** principal kind (just `User`) or **many**.
|
|
17
|
+
|
|
18
|
+
```csharp
|
|
19
|
+
public interface ISecurityPrincipal : IBusinessObject<long>
|
|
20
|
+
{
|
|
21
|
+
bool? IsDisabled { get; set; }
|
|
22
|
+
IReadOnlyCollection<IRole> Roles { get; }
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### IUser
|
|
27
|
+
|
|
28
|
+
A human principal that authenticates by email. Inherits identity, disabled-state, and roles from
|
|
29
|
+
`ISecurityPrincipal`; adds only `Email`.
|
|
30
|
+
|
|
31
|
+
```csharp
|
|
32
|
+
public interface IUser : ISecurityPrincipal
|
|
33
|
+
{
|
|
34
|
+
string Email { get; set; }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### IRole
|
|
39
|
+
|
|
40
|
+
```csharp
|
|
41
|
+
public interface IRole : IBusinessObject<int>
|
|
42
|
+
{
|
|
43
|
+
string Name { get; set; }
|
|
44
|
+
IReadOnlyCollection<IUser> Users { get; }
|
|
45
|
+
IReadOnlyCollection<IPermission> Permissions { get; }
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### IPermission
|
|
50
|
+
|
|
51
|
+
```csharp
|
|
52
|
+
public interface IPermission : IReadonlyObject<int>
|
|
53
|
+
{
|
|
54
|
+
string Name { get; set; }
|
|
55
|
+
string Code { get; set; }
|
|
56
|
+
IReadOnlyCollection<IRole> Roles { get; }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Real-World Entity Example
|
|
61
|
+
|
|
62
|
+
```csharp
|
|
63
|
+
[Index(nameof(Email), IsUnique = true)]
|
|
64
|
+
public class User : BusinessObject<long>, IUser
|
|
65
|
+
{
|
|
66
|
+
[Required]
|
|
67
|
+
[StringLength(70, MinimumLength = 5)]
|
|
68
|
+
[Email]
|
|
69
|
+
public string Email { get; set; }
|
|
70
|
+
|
|
71
|
+
public string FirstName { get; set; }
|
|
72
|
+
public string LastName { get; set; }
|
|
73
|
+
|
|
74
|
+
public bool? HasLoggedInWithGoogleAsExternalProvider { get; set; }
|
|
75
|
+
public bool? IsDisabled { get; set; }
|
|
76
|
+
|
|
77
|
+
public virtual List<Role> Roles { get; } = new();
|
|
78
|
+
IReadOnlyCollection<IRole> ISecurityPrincipal.Roles => Roles; // Roles moved to the principal base
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public class Role : BusinessObject<int>, IRole
|
|
82
|
+
{
|
|
83
|
+
[Required]
|
|
84
|
+
[StringLength(255, MinimumLength = 1)]
|
|
85
|
+
public string Name { get; set; }
|
|
86
|
+
|
|
87
|
+
[UIControlType(nameof(UIControlTypeCodes.MultiAutocomplete))]
|
|
88
|
+
public virtual List<User> Users { get; } = new();
|
|
89
|
+
IReadOnlyCollection<IUser> IRole.Users => Users;
|
|
90
|
+
|
|
91
|
+
[UIControlType(nameof(UIControlTypeCodes.MultiSelect))]
|
|
92
|
+
public virtual List<Permission> Permissions { get; } = new();
|
|
93
|
+
IReadOnlyCollection<IPermission> IRole.Permissions => Permissions;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[UIDoNotGenerate]
|
|
97
|
+
[Index(nameof(Code), IsUnique = true)]
|
|
98
|
+
public class Permission : ReadonlyObject<int>, IPermission
|
|
99
|
+
{
|
|
100
|
+
[Required]
|
|
101
|
+
[StringLength(100, MinimumLength = 1)]
|
|
102
|
+
public string Name { get; set; }
|
|
103
|
+
|
|
104
|
+
[Required]
|
|
105
|
+
[StringLength(100, MinimumLength = 1)]
|
|
106
|
+
public string Code { get; set; }
|
|
107
|
+
|
|
108
|
+
public virtual List<Role> Roles { get; } = new();
|
|
109
|
+
IReadOnlyCollection<IRole> IPermission.Roles => Roles;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Permission Code Convention
|
|
114
|
+
|
|
115
|
+
Auto-generated per entity (via `PermissionCodesGenerator`):
|
|
116
|
+
|
|
117
|
+
| Code | Purpose |
|
|
118
|
+
| ---------------- | ----------------- |
|
|
119
|
+
| `Read{Entity}` | View list/details |
|
|
120
|
+
| `Update{Entity}` | Modify existing |
|
|
121
|
+
| `Insert{Entity}` | Create new |
|
|
122
|
+
| `Delete{Entity}` | Remove |
|
|
123
|
+
|
|
124
|
+
Generated as a partial class:
|
|
125
|
+
|
|
126
|
+
```csharp
|
|
127
|
+
public static partial class PermissionCodes
|
|
128
|
+
{
|
|
129
|
+
public static string ReadProduct { get; } = "ReadProduct";
|
|
130
|
+
public static string UpdateProduct { get; } = "UpdateProduct";
|
|
131
|
+
public static string InsertProduct { get; } = "InsertProduct";
|
|
132
|
+
public static string DeleteProduct { get; } = "DeleteProduct";
|
|
133
|
+
// ... one set per entity
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Extend with custom codes:
|
|
138
|
+
|
|
139
|
+
```csharp
|
|
140
|
+
public static partial class PermissionCodes
|
|
141
|
+
{
|
|
142
|
+
public static string ExportReports { get; } = "ExportReports";
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Seeding Permissions
|
|
147
|
+
|
|
148
|
+
In `ApplicationDbContext.SeedData()`:
|
|
149
|
+
|
|
150
|
+
```csharp
|
|
151
|
+
private static void SeedData(ModelBuilder modelBuilder)
|
|
152
|
+
{
|
|
153
|
+
DateTime seedDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
154
|
+
|
|
155
|
+
Permission[] permissions =
|
|
156
|
+
[
|
|
157
|
+
new Permission { Id = 1, Name = "View users", Code = "ReadUser" },
|
|
158
|
+
new Permission { Id = 2, Name = "Edit users", Code = "UpdateUser" },
|
|
159
|
+
new Permission { Id = 3, Name = "Add users", Code = "InsertUser" },
|
|
160
|
+
new Permission { Id = 4, Name = "Delete users", Code = "DeleteUser" },
|
|
161
|
+
new Permission { Id = 5, Name = "View products", Code = "ReadProduct" },
|
|
162
|
+
// ... more permissions with sequential IDs
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
modelBuilder.Entity<Permission>().HasData(permissions);
|
|
166
|
+
|
|
167
|
+
modelBuilder.Entity<Role>().HasData(new Role
|
|
168
|
+
{
|
|
169
|
+
Id = 1,
|
|
170
|
+
Name = "Admin",
|
|
171
|
+
CreatedAt = seedDate,
|
|
172
|
+
ModifiedAt = seedDate,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
modelBuilder.Entity<Role>()
|
|
176
|
+
.HasMany(r => r.Permissions)
|
|
177
|
+
.WithMany(p => p.Roles)
|
|
178
|
+
.UsingEntity(j => j.HasData(
|
|
179
|
+
permissions.Select((p, i) => new { RoleId = 1, PermissionId = p.Id }).ToArray()
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
After adding new permissions: `spiderly add-migration AddNewPermissions` → `spiderly update-database`.
|
|
185
|
+
|
|
186
|
+
## First-User Bootstrap
|
|
187
|
+
|
|
188
|
+
Permissions and an "Admin" role are seeded via `HasData()` in the generated `ApplicationDbContext` (applied through EF migrations). On first login, `SecurityService.OnAfterLogin` auto-grants Admin to the user — no manual bootstrap needed on a fresh deploy.
|
|
189
|
+
|
|
190
|
+
## Attributes
|
|
191
|
+
|
|
192
|
+
### `[DoNotAuthorize]`
|
|
193
|
+
|
|
194
|
+
Skip all permission checks for an entity:
|
|
195
|
+
|
|
196
|
+
```csharp
|
|
197
|
+
[DoNotAuthorize]
|
|
198
|
+
public class PaymentMethod : ReadonlyObject<byte> { ... }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use for public lookup tables. Generated CRUD endpoints won't require login.
|
|
202
|
+
|
|
203
|
+
### `[AuthGuard]`
|
|
204
|
+
|
|
205
|
+
Require valid JWT on a controller action:
|
|
206
|
+
|
|
207
|
+
```csharp
|
|
208
|
+
[HttpGet]
|
|
209
|
+
[AuthGuard]
|
|
210
|
+
public async Task<UserBaseDTO> GetProfile() { ... }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Validates JWT from `Authorization: Bearer {token}` header. Returns 401 if invalid. Applied automatically on all generated CRUD endpoints (unless entity has `[DoNotAuthorize]`).
|
|
214
|
+
|
|
215
|
+
## Generated Authorization Service
|
|
216
|
+
|
|
217
|
+
`AuthorizationServicesGenerator` creates per-entity authorization methods:
|
|
218
|
+
|
|
219
|
+
```csharp
|
|
220
|
+
// Generated — override in your AuthorizationService to customize.
|
|
221
|
+
// The call is principal-kind-agnostic: it authorizes the current principal (whatever kind) by
|
|
222
|
+
// resolving it through the principal registry — no compile-time user type.
|
|
223
|
+
public virtual async Task AuthorizeProductReadAndThrow(long? productIdToRead)
|
|
224
|
+
{
|
|
225
|
+
await AuthorizeAndThrowAsync(PermissionCodes.ReadProduct);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public virtual async Task AuthorizeProductUpdateAndThrow(ProductDTO dto)
|
|
229
|
+
{
|
|
230
|
+
await AuthorizeAndThrowAsync(PermissionCodes.UpdateProduct);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public virtual async Task AuthorizeProductInsertAndThrow(ProductDTO dto)
|
|
234
|
+
{
|
|
235
|
+
await AuthorizeAndThrowAsync(PermissionCodes.InsertProduct);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public virtual async Task AuthorizeProductDeleteAndThrow(long id)
|
|
239
|
+
{
|
|
240
|
+
await AuthorizeAndThrowAsync(PermissionCodes.DeleteProduct);
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Registering Principal Kinds
|
|
245
|
+
|
|
246
|
+
Register each principal kind in `AddAppServices` so authorization can resolve the current principal.
|
|
247
|
+
A single-principal app registers just `User`; the kind-dispatched authorization then resolves it
|
|
248
|
+
without a `principal_kind` claim (it is the sole, default kind):
|
|
249
|
+
|
|
250
|
+
```csharp
|
|
251
|
+
services.AddSpiderlyPrincipal<User>("User");
|
|
252
|
+
// Add a line per additional principal kind, e.g.:
|
|
253
|
+
// services.AddSpiderlyPrincipal<ServiceAccount>("ServiceAccount");
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Each kind is any entity implementing `ISecurityPrincipal` with its own `Roles` into the shared
|
|
257
|
+
Role/Permission catalog. (`spiderly init` scaffolds the `User` registration for you.)
|
|
258
|
+
|
|
259
|
+
## Checking Permissions in Custom Code
|
|
260
|
+
|
|
261
|
+
```csharp
|
|
262
|
+
// Preferred — authorizes the current principal whatever its kind:
|
|
263
|
+
await _authorizationService.AuthorizeAndThrowAsync(PermissionCodes.ExportReports);
|
|
264
|
+
|
|
265
|
+
// Check without throwing:
|
|
266
|
+
bool canExport = await _authorizationService.IsAuthorizedAsync(PermissionCodes.ExportReports);
|
|
267
|
+
|
|
268
|
+
// The generic overload still exists for an explicit single-type check (e.g. a User-only admin path):
|
|
269
|
+
await _authorizationService.AuthorizeAndThrowAsync<User>(PermissionCodes.ExportReports);
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Authentication Flow
|
|
273
|
+
|
|
274
|
+
Spiderly uses **email-based login** (no passwords):
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
1. Client sends email → SendLoginVerificationEmail
|
|
278
|
+
2. Server sends 6-digit code via email (or shows in dev mode)
|
|
279
|
+
3. Client sends code → Login
|
|
280
|
+
4. Server returns access token (JWT, 20 min) + refresh token (24h)
|
|
281
|
+
5. Auto-refresh 5 seconds before expiration
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### SecurityServiceBase Hooks
|
|
285
|
+
|
|
286
|
+
```csharp
|
|
287
|
+
public class SecurityService : SecurityServiceBase<User>
|
|
288
|
+
{
|
|
289
|
+
public override async Task OnAfterLogin(AuthResultDTO authResultDTO)
|
|
290
|
+
{
|
|
291
|
+
// Custom post-login logic (analytics, logging, etc.)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### SecurityBaseController Endpoints
|
|
297
|
+
|
|
298
|
+
The full auth API surface — every endpoint, its HTTP method, whether it needs a valid access token, and what it does — is generated from `SecurityBaseController`: see [references/security-endpoints.generated.md](references/security-endpoints.generated.md).
|
|
299
|
+
|
|
300
|
+
## API error codes
|
|
301
|
+
|
|
302
|
+
Failed requests return an `ApiErrorDTO` whose machine-readable `errorCode` clients switch on (the Angular interceptor, storefront middleware, external API consumers). The full list — names, wire values, and when each is returned — is generated from the `ApiErrorCodes` contract: see [references/api-error-codes.generated.md](references/api-error-codes.generated.md).
|
|
303
|
+
|
|
304
|
+
## Google OAuth Setup
|
|
305
|
+
|
|
306
|
+
1. Get a Google Client ID from Google Developer Console
|
|
307
|
+
2. Set in `Backend/appsettings.json`:
|
|
308
|
+
```json
|
|
309
|
+
{ "AppSettings": { "Spiderly.Shared": { "GoogleClientId": "..." } } }
|
|
310
|
+
```
|
|
311
|
+
3. Set in `Frontend/src/environments/environment.ts`:
|
|
312
|
+
```typescript
|
|
313
|
+
GoogleClientId: "...";
|
|
314
|
+
```
|
|
315
|
+
4. Enable in config service:
|
|
316
|
+
```typescript
|
|
317
|
+
override showGoogleAuth = true;
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Flow: Google returns JWT → `LoginExternal` validates → auto-creates user if new → returns tokens.
|
|
321
|
+
|
|
322
|
+
## Frontend Auth
|
|
323
|
+
|
|
324
|
+
### AuthServiceBase
|
|
325
|
+
|
|
326
|
+
Key observables:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
user$: Observable<UserBase | null>; // Current user
|
|
330
|
+
currentUserPermissionCodes$: Observable<string[]>; // Permission codes
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Key methods:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
login(body: VerificationTokenRequest): Observable<Promise<AuthResult>>
|
|
337
|
+
loginExternal(body: ExternalProvider): Observable<Promise<AuthResult>>
|
|
338
|
+
logout()
|
|
339
|
+
refreshToken(): Observable<AuthResult>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Overridable hooks:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
onAfterLoginExternal = () => { ... }
|
|
346
|
+
onAfterLogout = () => { ... }
|
|
347
|
+
onAfterRefreshToken = () => { ... }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Route Guards
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// Protect authenticated routes
|
|
354
|
+
{ path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] }
|
|
355
|
+
|
|
356
|
+
// Protect login page from logged-in users
|
|
357
|
+
{ path: 'login', component: LoginComponent, canActivate: [NotAuthGuard] }
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Permission-Based Menu Visibility
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
menu: SpiderlyMenuItem[] = [
|
|
364
|
+
{
|
|
365
|
+
label: 'Users',
|
|
366
|
+
routerLink: ['/user-list'],
|
|
367
|
+
hasPermission: (codes) => codes.includes('ReadUser'),
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Multi-Tab Sync
|
|
373
|
+
|
|
374
|
+
Login/logout events sync across browser tabs via `localStorage` events. `getBrowserId()` generates a UUID per browser — server limits to 5 concurrent sessions per user.
|
|
375
|
+
|
|
376
|
+
## Settings Reference
|
|
377
|
+
|
|
378
|
+
```csharp
|
|
379
|
+
AccessTokenExpiration = 20 // minutes
|
|
380
|
+
RefreshTokenExpiration = 1440 // minutes (24h)
|
|
381
|
+
VerificationTokenExpiration = 5 // minutes
|
|
382
|
+
AllowedBrowsersForTheSingleUser = 5
|
|
383
|
+
OnlyAdminCanAddUsers = false // true = block self-registration
|
|
384
|
+
AllowTheUseOfAppWithDifferentIpAddresses = true
|
|
385
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- GENERATED FROM framework-metadata.json — DO NOT EDIT.
|
|
2
|
+
Regenerate: `dotnet run --project Spiderly.MetadataExporter -- --out framework-metadata.json && node tools/extract-ts-metadata.mjs && node tools/gen-skill-docs.mjs` -->
|
|
3
|
+
|
|
4
|
+
# API error codes
|
|
5
|
+
|
|
6
|
+
Machine-readable error codes returned in ErrorCode. Treat as a public contract — clients (Angular interceptor, storefront middleware, external API consumers) switch on these values.
|
|
7
|
+
|
|
8
|
+
| Name | Value | Description |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `ConcurrencyConflict` | `concurrency_conflict` | An optimistic-concurrency check failed — the row was modified by someone else after it was loaded. The client should reload the latest data and retry. |
|
|
11
|
+
| `EmailNotVerified` | `email_not_verified` | Login or auto-provisioning was blocked because the account's email address is not verified (e.g. an external provider returned an unverified email). |
|
|
12
|
+
| `ExternalEmailMissing` | `external_email_missing` | An external (OAuth/OIDC) login was validated but the provider returned no email address (e.g. the user declined the email permission, or a phone-only Facebook account). Auto-provisioning needs an email to key the account on, so login is rejected with this code and the client should route the user to another sign-in method. Distinct from EmailNotVerified, which means an email was returned but not verified. |
|
|
13
|
+
| `ExternalProviderNotConfigured` | `external_provider_not_configured` | An external (OAuth) login was attempted for a provider that is not configured on the server, or that provider's token exchange failed. |
|
|
14
|
+
| `ForeignKeyViolation` | `foreign_key_violation` | A database foreign-key constraint was violated — e.g. referencing a row that does not exist, or deleting a row that is still referenced by dependent rows. |
|
|
15
|
+
| `InvalidToken` | `invalid_token` | The JWT bearer token is missing, malformed, or expired. Returned with HTTP 401 (also surfaced in the WWW-Authenticate header); the client should refresh the token or re-authenticate. |
|
|
16
|
+
| `UniqueViolation` | `unique_violation` | A database unique constraint (or unique index) was violated — e.g. saving a duplicate value for a column that must be unique. |
|
|
17
|
+
| `ValidationFailed` | `validation_failed` | One or more request fields failed server-side validation. Returned with HTTP 400; the per-field messages are carried in ApiErrorDTO.FieldErrors. |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- GENERATED FROM framework-metadata.json — DO NOT EDIT.
|
|
2
|
+
Regenerate: `dotnet run --project Spiderly.MetadataExporter -- --out framework-metadata.json && node tools/extract-ts-metadata.mjs && node tools/gen-skill-docs.mjs` -->
|
|
3
|
+
|
|
4
|
+
# SecurityBaseController endpoints
|
|
5
|
+
|
|
6
|
+
A base controller providing core security functionalities such as authentication, user management, and role-based access control. It leverages various services for handling user authentication (login, registration, logout, token refresh). This controller is designed to be extended for specific user types.
|
|
7
|
+
|
|
8
|
+
| Endpoint | Method | Auth | Description |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `ExternalLoginCallback` | GET | No | Server-side external-login step 2: the provider redirects here with the code. Exchanges it for the id token (server-side), validates + links the user, issues the session as HttpOnly cookies, and redirects back to the originating app (returnUrl). This is a top-level browser navigation, so failures must redirect back to the app (never render a JSON error onto the API origin). The app surfaces a friendly message via the externalAuthError hint: expired (the state cookie lapsed — the user lingered on the provider's picker) or failed (invalid state/nonce, failed code exchange, or denied consent). |
|
|
11
|
+
| `ExternalLoginChallenge` | GET | No | Server-side external-login step 1: redirects the browser to the provider's authorize endpoint. The state/nonce/PKCE-verifier are stored in a short-lived, Data-Protection-signed HttpOnly cookie. |
|
|
12
|
+
| `GetCurrentUserBase` | GET | Yes | Returns the authenticated user's base profile (id, email, core fields). Requires a valid access token. |
|
|
13
|
+
| `GetCurrentUserPermissionCodes` | GET | Yes | Returns the permission codes granted to the authenticated user via their roles. Requires a valid access token. |
|
|
14
|
+
| `GetExternalLoginNonce` | GET | No | Issues a one-time nonce for the client-side (GIS / id-token) external-login flow: returns the raw nonce for the SPA to pass to the provider's sign-in call (so it is echoed into the id token), and stores a signed copy in a short-lived HttpOnly cookie that LoginExternal / LoginExternalWithCookies verify the returned id token against. Anonymous. SameSite=None so the cookie rides the cross-site login POST. |
|
|
15
|
+
| `GetExternalProviders` | GET | No | Public list of enabled external providers (code + OIDC authority + client id + button display), so the frontend can render sign-in buttons and run the client OIDC flow. Anonymous — the values are public by OIDC design. |
|
|
16
|
+
| `Login` | POST | No | Passwordless login step 2: verifies the emailed code and, on success, returns the access + refresh tokens in the response body. Anonymous. |
|
|
17
|
+
| `LoginExternal` | POST | No | Client-side external (OIDC) login: validates the provider id token against the single-use nonce cookie and returns the access + refresh tokens in the response body. Anonymous. |
|
|
18
|
+
| `LoginExternalWithCookies` | POST | No | Like LoginExternal, but issues the session as HttpOnly cookies instead of returning the tokens in the response body. Anonymous. |
|
|
19
|
+
| `LoginWithCookies` | POST | No | Like Login, but issues the session as HttpOnly cookies instead of returning the tokens in the response body. Anonymous. |
|
|
20
|
+
| `Logout` | GET | Yes | Invalidates the current user's refresh token for the given browser session. Requires a valid access token. |
|
|
21
|
+
| `LogoutWithCookies` | GET | Yes | Like Logout, and additionally clears the auth HttpOnly cookies. Requires a valid access token. |
|
|
22
|
+
| `RefreshTokenWithCookies` | POST | No | Refreshes the access token using the refresh token stored in an HttpOnly cookie. POST (not GET) because it mutates server state — it rotates the single-use refresh token. A safe/idempotent GET was cacheable, so browsers replayed a stale "logged-in" body on back/forward navigations (phantom dashboard after logout); POST is never cached, and the controller-level no-store is belt-and-braces. |
|
|
23
|
+
| `RefreshTokenWithHeaders` | POST | No | Refreshes the access token using the refresh token supplied in the request body, returning a new access + refresh token pair. Anonymous — the refresh token is itself the credential. |
|
|
24
|
+
| `SendLoginVerificationEmail` | POST | No | Passwordless login step 1: sends a short-lived numeric verification code to the user's email. Anonymous. |
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: backend-hooks
|
|
3
|
+
description: Override Spiderly lifecycle hooks to customize generated CRUD behavior. Use when overriding lifecycle hooks, customizing generated CRUD logic, adding business logic to save/delete/get operations, handling MARS exceptions or transaction issues, or throwing business/security-violation exceptions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Backend Hooks
|
|
7
|
+
|
|
8
|
+
## Inheritance Chain
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
ServiceBase (Spiderly.Shared — concrete base class)
|
|
12
|
+
↓
|
|
13
|
+
{Entity}ServiceGenerated (generated — per-entity virtual hooks)
|
|
14
|
+
↓
|
|
15
|
+
{Entity}Service (your code — override hooks here)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Each entity gets its own generated service class. All generated methods are `public virtual` or `protected virtual`. Override them by creating an `{Entity}Service` class that inherits from `{Entity}ServiceGenerated`. DI registration is fully auto-generated — the source generator detects your override class and registers it automatically.
|
|
19
|
+
|
|
20
|
+
The generated service receives `EntityServiceDependencies` (bundles `IApplicationDbContext`, `ExcelService`, `AuthorizationService`, `IFileManager`, `IStringLocalizer`, `IServiceProvider`). Access them via `_deps`.
|
|
21
|
+
|
|
22
|
+
## Hook Signatures by Phase
|
|
23
|
+
|
|
24
|
+
### Save Flow (execution order)
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
1. SaveBody validation (SaveBodyDTOValidationRules — usually empty)
|
|
28
|
+
2. OnBeforeSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO)
|
|
29
|
+
3. DTO validation ({Entity}DTOValidationRules — NotEmpty, Length, etc.)
|
|
30
|
+
4. OnBefore{Entity}IsMapped(DTO)
|
|
31
|
+
5. OnBefore{Entity}Insert(entity, DTO) — or — OnBefore{Entity}Update(entity, DTO)
|
|
32
|
+
6. SaveChangesAsync
|
|
33
|
+
7. Update M2M + ordered O2M collections
|
|
34
|
+
8. OnAfterSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO, MainUIFormDTO)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Step 2 runs **before** step 3 — this means `OnBeforeSave` can set server-generated fields (e.g., `[UIDoNotGenerate]` + `[Required]` properties like hashes or computed values) before DTO validation runs.
|
|
38
|
+
|
|
39
|
+
On the **update path**, the entity load before step 5b goes through `GetInstanceAsync(id, dto.Version)` — it throws a localized `ConcurrencyException` (HTTP 409) when the client's `Version` is stale, and the whole flow runs in a transaction. Generated saves are therefore protected by optimistic concurrency out of the box; don't add manual race-condition guards to hooks for plain concurrent edits. Mechanism and limits: `entity-design` skill, *Base Classes* (`BusinessObject` `Version`).
|
|
40
|
+
|
|
41
|
+
**Signatures:**
|
|
42
|
+
|
|
43
|
+
```csharp
|
|
44
|
+
// Step 2 — modify DTO before DTO validation
|
|
45
|
+
protected virtual async Task OnBeforeSave{Entity}AndReturnMainUIFormDTO(
|
|
46
|
+
{Entity}SaveBodyDTO saveBodyDTO) { }
|
|
47
|
+
|
|
48
|
+
// Step 4 — just before DTO→Entity mapping
|
|
49
|
+
protected virtual async Task OnBefore{Entity}IsMapped(
|
|
50
|
+
{Entity}DTO dto) { }
|
|
51
|
+
|
|
52
|
+
// Step 5a — after mapping, before insert
|
|
53
|
+
protected virtual async Task OnBefore{Entity}Insert(
|
|
54
|
+
{Entity} entity, {Entity}DTO dto) { }
|
|
55
|
+
|
|
56
|
+
// Step 5b — after loading from DB, before update
|
|
57
|
+
protected virtual async Task OnBefore{Entity}Update(
|
|
58
|
+
{Entity} entity, {Entity}DTO dto) { }
|
|
59
|
+
|
|
60
|
+
// Step 8 — after everything is saved
|
|
61
|
+
protected virtual async Task OnAfterSave{Entity}AndReturnMainUIFormDTO(
|
|
62
|
+
{Entity}SaveBodyDTO saveBodyDTO,
|
|
63
|
+
{Entity}MainUIFormDTO mainUIFormDTO) { }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Delete Hooks
|
|
67
|
+
|
|
68
|
+
```csharp
|
|
69
|
+
// Default forwards to OnBefore{Entity}ListDelete with a single-element list.
|
|
70
|
+
public virtual Task OnBefore{Entity}Delete({IdType} id) =>
|
|
71
|
+
OnBefore{Entity}ListDelete(id.StructToList());
|
|
72
|
+
|
|
73
|
+
public virtual async Task OnBefore{Entity}ListDelete(List<{IdType}> ids) { }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**When single and batch deletes need the same logic, override only the list hook** — the single-id path forwards to it automatically. Override the single hook only when the per-id behaviour genuinely diverges from the batch case.
|
|
77
|
+
|
|
78
|
+
### Get Hooks
|
|
79
|
+
|
|
80
|
+
```csharp
|
|
81
|
+
// After MainUIFormDTO is constructed (enrich with computed fields)
|
|
82
|
+
protected virtual async Task OnAfterGet{Entity}MainUIFormDTO(
|
|
83
|
+
{Entity}MainUIFormDTO mainUIFormDTO) { }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Paginated List (override the whole method)
|
|
87
|
+
|
|
88
|
+
Override in your `{Entity}Service`:
|
|
89
|
+
|
|
90
|
+
```csharp
|
|
91
|
+
public override async Task<PaginatedResultDTO<{Entity}DTO>> GetPaginated{Entity}List(
|
|
92
|
+
FilterDTO filterDTO, IQueryable<{Entity}> query, bool authorize)
|
|
93
|
+
{
|
|
94
|
+
query = query.Where(x => x.IsActive);
|
|
95
|
+
return await base.GetPaginated{Entity}List(filterDTO, query, authorize);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Blob/File Upload Hooks
|
|
100
|
+
|
|
101
|
+
```csharp
|
|
102
|
+
// Before upload authorization
|
|
103
|
+
public virtual async Task OnBefore{Property}BlobFor{Entity}UploadIsAuthorized(
|
|
104
|
+
IFormFile file, {IdType} id) { }
|
|
105
|
+
|
|
106
|
+
// Before upload to storage (transform bytes, validate)
|
|
107
|
+
public virtual async Task<byte[]> OnBefore{Property}BlobFor{Entity}IsUploaded(
|
|
108
|
+
Stream stream, IFormFile file, {IdType} id) { }
|
|
109
|
+
|
|
110
|
+
// Image-specific hooks (called by OnBefore*IsUploaded for image/* content types)
|
|
111
|
+
public virtual async Task ValidateImageFor{Property}Of{Entity}(
|
|
112
|
+
Stream stream, IFormFile file, {IdType} id) { }
|
|
113
|
+
public virtual async Task<byte[]> OptimizeImageFor{Property}Of{Entity}(
|
|
114
|
+
Stream stream, IFormFile file, {IdType} id) { }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Relationship Hooks
|
|
118
|
+
|
|
119
|
+
```csharp
|
|
120
|
+
// Customize the base query for M2M autocomplete/dropdown
|
|
121
|
+
protected virtual async Task<IQueryable<{Related}>> GetAll{Property}QueryFor{Entity}(
|
|
122
|
+
IQueryable<{Related}> query) { return query; }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## All Hooks Run Inside Transactions
|
|
126
|
+
|
|
127
|
+
Every generated method wraps its logic in `_context.WithTransactionAsync(...)`. Nested calls reuse the existing transaction. You do **not** need to start your own transaction in hooks.
|
|
128
|
+
|
|
129
|
+
**Generated CRUD operations flush the change tracker before commit**, so you can stage tracked writes — an entity `Add`/`Update`, or `IOutbox.Enqueue` — inside any `OnBefore...` hook (including `OnBefore{Entity}Delete`) and they persist atomically with the operation; no manual `SaveChangesAsync`. This holds even though the delete path deletes via untracked bulk `ExecuteDeleteAsync` — the operation still flushes whatever the hook staged. The catch: `WithTransactionAsync`'s clean-tracker-at-commit guard is a backstop, so if you stage a tracked write in a **custom** (non-hook) `WithTransactionAsync` block, you must `SaveChangesAsync` it yourself or the guard throws.
|
|
130
|
+
|
|
131
|
+
If you need a transaction in **custom** (non-hook) methods:
|
|
132
|
+
|
|
133
|
+
```csharp
|
|
134
|
+
await _context.WithTransactionAsync(async () =>
|
|
135
|
+
{
|
|
136
|
+
// all DB operations here are transactional
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Exception Types
|
|
141
|
+
|
|
142
|
+
| Type | HTTP Status | When to Use |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `BusinessException(message)` | 400 | Validation the user can trigger through normal UI usage |
|
|
145
|
+
| `SecurityViolationException()` | 403 | Impossible conditions, tampering, unauthorized access |
|
|
146
|
+
|
|
147
|
+
```csharp
|
|
148
|
+
throw new BusinessException("Sale price must be less than regular price.");
|
|
149
|
+
throw new SecurityViolationException(); // logs detailed message server-side, returns generic error
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Both surface in the admin UI automatically — the global HTTP-error interceptor toasts the `BusinessException` message (and a safe generic message for everything else). Throw and return; don't build a parallel error channel or per-call frontend handling.
|
|
153
|
+
|
|
154
|
+
## PostgreSQL MARS Pitfall
|
|
155
|
+
|
|
156
|
+
EF Core on PostgreSQL does **not** support Multiple Active Result Sets. You'll get a `NpgsqlOperationInProgressException` if you enumerate two queries concurrently.
|
|
157
|
+
|
|
158
|
+
**Fix 1 — Materialize with `.Select()` before starting another query:**
|
|
159
|
+
|
|
160
|
+
```csharp
|
|
161
|
+
// BAD — lazy enumeration holds the connection open
|
|
162
|
+
var dict = await _context.DbSet<Product>().ToDictionaryAsync(x => x.Id, x => x.Name);
|
|
163
|
+
|
|
164
|
+
// GOOD — materialize first
|
|
165
|
+
var dict = await _context.DbSet<Product>()
|
|
166
|
+
.Select(x => new { x.Id, x.Name })
|
|
167
|
+
.ToDictionaryAsync(x => x.Id, x => x.Name);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Fix 2 — Use `.Include()` instead of lazy loading navigation properties:**
|
|
171
|
+
|
|
172
|
+
```csharp
|
|
173
|
+
// BAD — accessing Product.Category triggers lazy load while connection is busy
|
|
174
|
+
var products = await _context.DbSet<Product>().ToListAsync();
|
|
175
|
+
var names = products.Select(p => p.Category.Name); // MARS error
|
|
176
|
+
|
|
177
|
+
// GOOD — eager load
|
|
178
|
+
var products = await _context.DbSet<Product>()
|
|
179
|
+
.Include(p => p.Category)
|
|
180
|
+
.ToListAsync();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Real-World Example
|
|
184
|
+
|
|
185
|
+
```csharp
|
|
186
|
+
public class ProductService : ProductServiceGenerated
|
|
187
|
+
{
|
|
188
|
+
private readonly MeilisearchService _meilisearchService;
|
|
189
|
+
private readonly StorefrontRevalidationService _storefrontRevalidationService;
|
|
190
|
+
|
|
191
|
+
public ProductService(
|
|
192
|
+
EntityServiceDependencies deps,
|
|
193
|
+
MeilisearchService meilisearchService,
|
|
194
|
+
StorefrontRevalidationService storefrontRevalidationService
|
|
195
|
+
) : base(deps)
|
|
196
|
+
{
|
|
197
|
+
_meilisearchService = meilisearchService;
|
|
198
|
+
_storefrontRevalidationService = storefrontRevalidationService;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
protected override async Task OnBeforeSaveProductAndReturnMainUIFormDTO(
|
|
202
|
+
ProductSaveBodyDTO saveBodyDTO)
|
|
203
|
+
{
|
|
204
|
+
// Validate variant prices
|
|
205
|
+
foreach (ProductVariantSaveBodyDTO v in saveBodyDTO.OrderedProductVariantsSaveBodyDTO)
|
|
206
|
+
{
|
|
207
|
+
if (v.ProductVariantDTO.SalePrice >= v.ProductVariantDTO.Price)
|
|
208
|
+
throw new BusinessException("Sale price must be less than regular price.");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Compute aggregate fields
|
|
212
|
+
saveBodyDTO.ProductDTO.Price = saveBodyDTO.OrderedProductVariantsSaveBodyDTO[0]
|
|
213
|
+
.ProductVariantDTO.Price;
|
|
214
|
+
saveBodyDTO.ProductDTO.Stock = saveBodyDTO.OrderedProductVariantsSaveBodyDTO
|
|
215
|
+
.Sum(v => v.ProductVariantDTO.Stock ?? 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
protected override async Task OnAfterSaveProductAndReturnMainUIFormDTO(
|
|
219
|
+
ProductSaveBodyDTO saveBodyDTO,
|
|
220
|
+
ProductMainUIFormDTO mainUIFormDTO)
|
|
221
|
+
{
|
|
222
|
+
// Cross-entity service call
|
|
223
|
+
ProductVariantServiceGenerated variantService =
|
|
224
|
+
_deps.ServiceProvider.GetRequiredService<ProductVariantServiceGenerated>();
|
|
225
|
+
|
|
226
|
+
// Side effects: indexing, cache invalidation
|
|
227
|
+
await _meilisearchService.IndexProduct(mainUIFormDTO.ProductDTO.Id);
|
|
228
|
+
_storefrontRevalidationService.RevalidateProducts(mainUIFormDTO.ProductDTO.Slug);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|