@webiny/mcp 6.1.0-beta.0 → 6.1.0-beta.2
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/agents/claude.d.ts +2 -0
- package/agents/claude.js +6 -0
- package/agents/claude.js.map +1 -1
- package/agents/cline.d.ts +2 -0
- package/agents/cline.js +6 -0
- package/agents/cline.js.map +1 -1
- package/agents/copilot.d.ts +2 -0
- package/agents/copilot.js +7 -0
- package/agents/copilot.js.map +1 -1
- package/agents/cursor.d.ts +2 -0
- package/agents/cursor.js +6 -0
- package/agents/cursor.js.map +1 -1
- package/agents/discover.d.ts +3 -0
- package/agents/discover.js +29 -0
- package/agents/discover.js.map +1 -0
- package/agents/instructions.d.ts +3 -1
- package/agents/instructions.js +15 -2
- package/agents/instructions.js.map +1 -1
- package/agents/kiro.d.ts +2 -0
- package/agents/kiro.js +6 -0
- package/agents/kiro.js.map +1 -1
- package/agents/opencode.d.ts +2 -0
- package/agents/opencode.js +7 -0
- package/agents/opencode.js.map +1 -1
- package/agents/types.d.ts +16 -0
- package/agents/types.js +3 -0
- package/agents/types.js.map +1 -0
- package/agents/windsurf.d.ts +2 -0
- package/agents/windsurf.js +6 -0
- package/agents/windsurf.js.map +1 -1
- package/cli/ConfigureMcp.js +21 -6
- package/cli/ConfigureMcp.js.map +1 -1
- package/package.json +3 -3
- package/skills/admin/admin-architect/SKILL.md +188 -0
- package/skills/admin/admin-permissions/SKILL.md +159 -0
- package/skills/api/api-architect/SKILL.md +548 -60
- package/skills/api/event-handler-pattern/SKILL.md +191 -23
- package/skills/api/graphql-api/SKILL.md +227 -31
- package/skills/api/permissions/SKILL.md +291 -0
- package/skills/api/use-case-pattern/SKILL.md +347 -12
- package/skills/api/v5-to-v6-migration/SKILL.md +416 -0
- package/skills/generated/admin/SKILL.md +1 -1
- package/skills/generated/admin/aco/SKILL.md +1 -1
- package/skills/generated/admin/build-params/SKILL.md +1 -1
- package/skills/generated/admin/cms/SKILL.md +1 -1
- package/skills/generated/admin/configs/SKILL.md +1 -1
- package/skills/generated/admin/env-config/SKILL.md +1 -1
- package/skills/generated/admin/form/SKILL.md +1 -1
- package/skills/generated/admin/graphql-client/SKILL.md +1 -1
- package/skills/generated/admin/lexical/SKILL.md +1 -1
- package/skills/generated/admin/local-storage/SKILL.md +1 -1
- package/skills/generated/admin/router/SKILL.md +1 -1
- package/skills/generated/admin/security/SKILL.md +1 -1
- package/skills/generated/admin/tenancy/SKILL.md +1 -1
- package/skills/generated/admin/ui/SKILL.md +1 -1
- package/skills/generated/admin/website-builder/SKILL.md +1 -1
- package/skills/generated/api/SKILL.md +1 -1
- package/skills/generated/api/aco/SKILL.md +1 -1
- package/skills/generated/api/build-params/SKILL.md +1 -1
- package/skills/generated/api/cms/SKILL.md +1 -1
- package/skills/generated/api/event-publisher/SKILL.md +1 -1
- package/skills/generated/api/file-manager/SKILL.md +1 -1
- package/skills/generated/api/graphql/SKILL.md +1 -1
- package/skills/generated/api/key-value-store/SKILL.md +1 -1
- package/skills/generated/api/logger/SKILL.md +1 -1
- package/skills/generated/api/opensearch/SKILL.md +1 -1
- package/skills/generated/api/scheduler/SKILL.md +1 -1
- package/skills/generated/api/security/SKILL.md +1 -1
- package/skills/generated/api/system/SKILL.md +1 -1
- package/skills/generated/api/tasks/SKILL.md +1 -1
- package/skills/generated/api/tenancy/SKILL.md +1 -1
- package/skills/generated/api/tenant-manager/SKILL.md +1 -1
- package/skills/generated/api/website-builder/SKILL.md +1 -1
- package/skills/generated/cli/SKILL.md +1 -1
- package/skills/generated/cli/command/SKILL.md +1 -1
- package/skills/generated/extensions/SKILL.md +1 -1
- package/skills/generated/infra/SKILL.md +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webiny-api-permissions
|
|
3
|
+
context: webiny-api
|
|
4
|
+
description: >
|
|
5
|
+
Schema-based permission system for API features. Use this skill when implementing
|
|
6
|
+
authorization in use cases, defining permission schemas with createPermissions,
|
|
7
|
+
checking read/write/delete/publish permissions, handling own-record scoping,
|
|
8
|
+
or testing permission scenarios. Covers the full pattern from schema definition
|
|
9
|
+
to use case integration to test matrices.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Schema-Based Permissions
|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
Webiny uses a **schema-based permission system** defined via `createPermissions`. Each package declares a permission schema and gets a typed `Permissions` abstraction injectable into use cases via DI. This replaces manual `identityContext.getPermission()` calls with high-level methods like `canRead`, `canEdit`, `canDelete`, `canPublish`, `onlyOwnRecords`, etc.
|
|
17
|
+
|
|
18
|
+
## Permission Schema Definition
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// domain/permissions.ts (or permissions/schema.ts)
|
|
22
|
+
import { createPermissions } from "@webiny/api-core/features/security/permissions/index.js";
|
|
23
|
+
import type { Permissions } from "@webiny/api-core/features/security/permissions/index.js";
|
|
24
|
+
|
|
25
|
+
const schema = {
|
|
26
|
+
prefix: "wb", // Permission prefix
|
|
27
|
+
fullAccess: { name: "wb.*" }, // Wildcard = full access to all entities
|
|
28
|
+
entities: [
|
|
29
|
+
{
|
|
30
|
+
id: "page", // Entity identifier (used in method calls)
|
|
31
|
+
permission: "wb.page", // Permission name stored on the identity
|
|
32
|
+
scopes: ["full", "own"], // Access scopes: "full" = all records, "own" = only own
|
|
33
|
+
actions: [
|
|
34
|
+
{ name: "rwd" }, // Read/Write/Delete (chars: "r", "w", "d")
|
|
35
|
+
{ name: "pw" } // Publish/Unpublish (chars: "p", "u")
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "settings",
|
|
40
|
+
permission: "wb.settings",
|
|
41
|
+
scopes: ["full"] // No "own" — no ownership concept for settings
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
} as const; // MUST use `as const` for type narrowing
|
|
45
|
+
|
|
46
|
+
type MySchema = typeof schema;
|
|
47
|
+
|
|
48
|
+
export const MyPermissions = createPermissions(schema);
|
|
49
|
+
|
|
50
|
+
export namespace MyPermissions {
|
|
51
|
+
export type Interface = Permissions<MySchema>;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`createPermissions` returns `{ Abstraction, Implementation }`. Register the Implementation in your feature.
|
|
56
|
+
|
|
57
|
+
### Schema Fields
|
|
58
|
+
|
|
59
|
+
| Field | Description |
|
|
60
|
+
| ----------------------- | --------------------------------------------------------------------- |
|
|
61
|
+
| `prefix` | Namespaces the DI abstraction: `${prefix}:Permissions` |
|
|
62
|
+
| `fullAccess.name` | Wildcard permission (e.g. `"wb.*"`) — grants all entity access |
|
|
63
|
+
| `entities[].id` | Entity identifier used in method calls: `canRead("page")` |
|
|
64
|
+
| `entities[].permission` | Permission name matched against identity permissions |
|
|
65
|
+
| `entities[].scopes` | `["full"]` or `["full", "own"]` — determines if own-scope supported |
|
|
66
|
+
| `entities[].actions` | Action definitions — built-in: `"rwd"`, `"pw"`; custom: boolean flags |
|
|
67
|
+
|
|
68
|
+
### Scopes
|
|
69
|
+
|
|
70
|
+
- **`"full"`** — User can access all records (default when no `own` flag on permission object)
|
|
71
|
+
- **`"own"`** — User can only access records where `createdBy.id === identity.id`
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Permission Methods
|
|
76
|
+
|
|
77
|
+
All methods follow a 3-tier bypass:
|
|
78
|
+
|
|
79
|
+
1. `identityContext.hasFullAccess()` → `name: "*"` permission (super admin)
|
|
80
|
+
2. `hasFullSchemaAccess()` → wildcard permission (e.g. `"wb.*"`)
|
|
81
|
+
3. Entity-level permission check
|
|
82
|
+
|
|
83
|
+
### Method Reference
|
|
84
|
+
|
|
85
|
+
| Method | Purpose | Item-aware | Notes |
|
|
86
|
+
| --------------------------- | --------------------- | ---------- | --------------------------------------------------------------------------------------------- |
|
|
87
|
+
| `canAccess(entity, item?)` | General access check | Yes | Without item: checks entity permission exists. With item + `own: true`: checks `createdBy.id` |
|
|
88
|
+
| `onlyOwnRecords(entity)` | List filter flag | No | Returns `true` when ALL permissions have `own: true` |
|
|
89
|
+
| `canRead(entity)` | Read permission | No | Checks `rwd` includes `"r"` (or no `rwd` = unrestricted) |
|
|
90
|
+
| `canCreate(entity)` | Create permission | No | Checks `rwd` includes `"w"` |
|
|
91
|
+
| `canEdit(entity, item?)` | Edit permission | Yes | With `own: true` + no item → allows (new/unsaved). With item → checks ownership |
|
|
92
|
+
| `canDelete(entity, item?)` | Delete permission | Yes | With `own: true` + no item → **RETURNS FALSE**. Must pass item |
|
|
93
|
+
| `canPublish(entity)` | Publish permission | No | Checks `pw` includes `"p"` |
|
|
94
|
+
| `canUnpublish(entity)` | Unpublish permission | No | Checks `pw` includes `"u"` |
|
|
95
|
+
| `canAction(action, entity)` | Custom boolean action | No | Checks `permission[action] === true` |
|
|
96
|
+
|
|
97
|
+
All return `Promise<boolean>`.
|
|
98
|
+
|
|
99
|
+
### OwnableItem Interface
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
interface OwnableItem {
|
|
103
|
+
createdBy?: { id: string } | null;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Use Case Implementation Patterns
|
|
110
|
+
|
|
111
|
+
### Get Use Case (Read + Ownership Gate)
|
|
112
|
+
|
|
113
|
+
The Get use case is the **central ownership gate** — mutation use cases that delegate to GetById inherit ownership enforcement automatically.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { Result } from "@webiny/feature/api";
|
|
117
|
+
import { GetByIdUseCase as UseCaseAbstraction, GetByIdRepository } from "./abstractions.js";
|
|
118
|
+
import { MyPermissions } from "~/domain/permissions.js";
|
|
119
|
+
import { NotAuthorizedError } from "~/domain/errors.js";
|
|
120
|
+
|
|
121
|
+
class GetByIdUseCaseImpl implements UseCaseAbstraction.Interface {
|
|
122
|
+
constructor(
|
|
123
|
+
private permissions: MyPermissions.Interface,
|
|
124
|
+
private repository: GetByIdRepository.Interface
|
|
125
|
+
) {}
|
|
126
|
+
|
|
127
|
+
async execute(id: string): UseCaseAbstraction.Return {
|
|
128
|
+
// 1. Entity-level read check
|
|
129
|
+
if (!(await this.permissions.canRead("entity"))) {
|
|
130
|
+
return Result.fail(new NotAuthorizedError());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 2. Fetch
|
|
134
|
+
const result = await this.repository.execute(id);
|
|
135
|
+
if (result.isFail()) {
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. Item-level ownership check
|
|
140
|
+
if (!(await this.permissions.canAccess("entity", result.value))) {
|
|
141
|
+
return Result.fail(new NotAuthorizedError());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const GetByIdUseCase = UseCaseAbstraction.createImplementation({
|
|
149
|
+
implementation: GetByIdUseCaseImpl,
|
|
150
|
+
dependencies: [MyPermissions.Abstraction, GetByIdRepository]
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### List Use Case (Read + Own Records Filter)
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js";
|
|
158
|
+
|
|
159
|
+
class ListUseCaseImpl implements UseCaseAbstraction.Interface {
|
|
160
|
+
constructor(
|
|
161
|
+
private permissions: MyPermissions.Interface,
|
|
162
|
+
private identityContext: IdentityContext.Interface,
|
|
163
|
+
private repository: ListRepository.Interface
|
|
164
|
+
) {}
|
|
165
|
+
|
|
166
|
+
async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return {
|
|
167
|
+
if (!(await this.permissions.canRead("entity"))) {
|
|
168
|
+
return Result.fail(new NotAuthorizedError());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const where = { ...params.where };
|
|
172
|
+
|
|
173
|
+
// Filter to own records if needed
|
|
174
|
+
if (await this.permissions.onlyOwnRecords("entity")) {
|
|
175
|
+
const identity = this.identityContext.getIdentity();
|
|
176
|
+
where.createdBy = identity.id;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return this.repository.execute({ ...params, where });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Dependencies must include IdentityContext
|
|
184
|
+
dependencies: [MyPermissions.Abstraction, IdentityContext, ListRepository];
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Important:** The list `where` type must include `createdBy?: string`. For CMS-based entities, `CmsEntryListWhere` already has this.
|
|
188
|
+
|
|
189
|
+
### Update Use Case (Edit + Item-Level Check)
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
class UpdateUseCaseImpl implements UseCaseAbstraction.Interface {
|
|
193
|
+
constructor(
|
|
194
|
+
private permissions: MyPermissions.Interface,
|
|
195
|
+
private getById: GetByIdUseCase.Interface, // Delegates ownership gate
|
|
196
|
+
private repository: UpdateRepository.Interface
|
|
197
|
+
) {}
|
|
198
|
+
|
|
199
|
+
async execute(id: string, data: UpdateData): UseCaseAbstraction.Return {
|
|
200
|
+
// 1. Entity-level edit check (no item yet)
|
|
201
|
+
if (!(await this.permissions.canEdit("entity"))) {
|
|
202
|
+
return Result.fail(new NotAuthorizedError());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Fetch original (enforces canRead + canAccess via GetById)
|
|
206
|
+
const getResult = await this.getById.execute(id);
|
|
207
|
+
if (getResult.isFail()) {
|
|
208
|
+
return getResult;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const original = getResult.value;
|
|
212
|
+
|
|
213
|
+
// 3. Item-level edit check (defense in depth)
|
|
214
|
+
if (!(await this.permissions.canEdit("entity", original))) {
|
|
215
|
+
return Result.fail(new NotAuthorizedError());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ... events + repository
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Delete Use Case (CRITICAL: Item-Level Delete)
|
|
224
|
+
|
|
225
|
+
**`canDelete` with `own: true` and no item returns `false`.**
|
|
226
|
+
|
|
227
|
+
Unlike `canEdit` (which returns `true` for `own: true` + no item), `canDelete` requires the item to verify ownership. The delete use case MUST fetch the item first.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
class DeleteUseCaseImpl implements UseCaseAbstraction.Interface {
|
|
231
|
+
async execute(params: Params): UseCaseAbstraction.Return {
|
|
232
|
+
// Fetch first (enforces canRead + canAccess via GetById)
|
|
233
|
+
const getResult = await this.getById.execute(params.id);
|
|
234
|
+
if (getResult.isFail()) {
|
|
235
|
+
return Result.fail(getResult.error);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const item = getResult.value;
|
|
239
|
+
|
|
240
|
+
// Item-level delete check — MUST pass the item
|
|
241
|
+
if (!(await this.permissions.canDelete("entity", item))) {
|
|
242
|
+
return Result.fail(new NotAuthorizedError());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ... events + repository
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Publish Use Case (Publish + Ownership)
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
class PublishUseCaseImpl {
|
|
254
|
+
async execute(params: Params): UseCaseAbstraction.Return {
|
|
255
|
+
// 1. Entity-level publish check
|
|
256
|
+
if (!(await this.permissions.canPublish("entity"))) {
|
|
257
|
+
return Result.fail(new NotAuthorizedError());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 2. Fetch (enforces ownership via GetById)
|
|
261
|
+
const getResult = await this.getById.execute(params.id);
|
|
262
|
+
if (getResult.isFail()) {
|
|
263
|
+
return getResult;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. Item-level ownership check (defense in depth)
|
|
267
|
+
if (!(await this.permissions.canAccess("entity", getResult.value))) {
|
|
268
|
+
return Result.fail(new NotAuthorizedError());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ... events + repository
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Gotchas
|
|
279
|
+
|
|
280
|
+
1. **`canDelete` without item + `own: true` = `false`** — Always pass the item to `canDelete`. Fetch first, then check.
|
|
281
|
+
2. **`canEdit` without item + `own: true` = `true`** — Intentional: allows editing new/unsaved records.
|
|
282
|
+
3. **`canAccess` without item = `true`** — Only checks entity-level access, not ownership.
|
|
283
|
+
4. **List where type** — Ensure the `where` interface includes `createdBy?: string` for own-scope filtering.
|
|
284
|
+
5. **`as const`** — The schema MUST use `as const` for TypeScript to narrow entity IDs in method signatures.
|
|
285
|
+
6. **Dependencies order** — DI constructor params must match the `dependencies` array order exactly.
|
|
286
|
+
|
|
287
|
+
## Related Skills
|
|
288
|
+
|
|
289
|
+
- **webiny-api-architect** — Architecture overview, Services vs UseCases, feature structure
|
|
290
|
+
- **webiny-use-case-pattern** — UseCase implementation, Result handling, decorators
|
|
291
|
+
- **webiny-dependency-injection** — Injectable services catalog
|
|
@@ -2,20 +2,19 @@
|
|
|
2
2
|
name: webiny-use-case-pattern
|
|
3
3
|
context: webiny-api
|
|
4
4
|
description: >
|
|
5
|
-
|
|
6
|
-
Use this skill to
|
|
5
|
+
UseCase implementation pattern — DI, Result handling, error types, decorators, CMS repositories,
|
|
6
|
+
entry mappers, and schema-based permissions. Use this skill to implement, inject, override, or
|
|
7
|
+
decorate any Webiny UseCase, or to build repositories that persist data via CMS.
|
|
7
8
|
---
|
|
8
9
|
|
|
9
10
|
# UseCase Pattern
|
|
10
11
|
|
|
11
12
|
## What It Is
|
|
12
13
|
|
|
13
|
-
A **UseCase** is a
|
|
14
|
+
A **UseCase** is a single-method orchestrator that encapsulates one business operation (e.g., `CreateTenantUseCase`, `PublishEntryUseCase`). Each UseCase is a DI abstraction with an `execute` method that returns `Result<T, E>`.
|
|
14
15
|
|
|
15
16
|
## Interface Shape
|
|
16
17
|
|
|
17
|
-
Every UseCase follows this pattern:
|
|
18
|
-
|
|
19
18
|
```ts
|
|
20
19
|
interface SomeUseCase.Interface {
|
|
21
20
|
execute(input: Input): Promise<Result<ReturnType, ErrorType>>;
|
|
@@ -28,7 +27,7 @@ interface SomeUseCase.Interface {
|
|
|
28
27
|
|
|
29
28
|
## How to Use a UseCase
|
|
30
29
|
|
|
31
|
-
UseCases are injected as dependencies into EventHandlers
|
|
30
|
+
UseCases are injected as dependencies into EventHandlers, other UseCases, or GraphQL resolvers via DI.
|
|
32
31
|
|
|
33
32
|
```ts
|
|
34
33
|
import { SomeUseCase } from "webiny/api/<category>";
|
|
@@ -85,18 +84,354 @@ export default SomeUseCase.createImplementation({
|
|
|
85
84
|
|
|
86
85
|
Deploy with: `yarn webiny deploy api --env=dev`
|
|
87
86
|
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Error Handling Pattern
|
|
90
|
+
|
|
91
|
+
### Domain-Specific Errors
|
|
92
|
+
|
|
93
|
+
Every feature defines errors extending `BaseError`. Never use generic `Error` for validation or business rule failures.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// domain/errors.ts
|
|
97
|
+
import { BaseError } from "@webiny/feature/api";
|
|
98
|
+
|
|
99
|
+
export class EntityNotFoundError extends BaseError {
|
|
100
|
+
override readonly code = "Entity/NotFound" as const;
|
|
101
|
+
constructor(id: string) {
|
|
102
|
+
super({ message: `Entity with id "${id}" was not found!` });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class EntityPersistenceError extends BaseError<{ error: Error }> {
|
|
107
|
+
override readonly code = "Entity/Persist" as const;
|
|
108
|
+
constructor(error: Error) {
|
|
109
|
+
super({ message: error.message, data: { error } });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class EntityValidationError extends BaseError<{ message: string }> {
|
|
114
|
+
override readonly code = "Entity/Validation" as const;
|
|
115
|
+
constructor(message: string) {
|
|
116
|
+
super({ message, data: { message } });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Typed Error Unions in Abstractions
|
|
122
|
+
|
|
123
|
+
Define an `IErrors` interface mapping error names to types, then create a union via `[keyof IErrors]`:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// features/createEntity/abstractions.ts
|
|
127
|
+
import { createAbstraction, Result } from "@webiny/feature/api";
|
|
128
|
+
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
|
|
129
|
+
import { EntityPersistenceError, EntityModelNotFoundError, EntityCreationError } from "~/api/domain/errors.js";
|
|
130
|
+
|
|
131
|
+
// REPOSITORY errors
|
|
132
|
+
export interface ICreateEntityRepositoryErrors {
|
|
133
|
+
persistence: EntityPersistenceError;
|
|
134
|
+
modelNotFound: EntityModelNotFoundError;
|
|
135
|
+
creation: EntityCreationError;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type RepositoryError = ICreateEntityRepositoryErrors[keyof ICreateEntityRepositoryErrors];
|
|
139
|
+
|
|
140
|
+
export interface ICreateEntityRepository {
|
|
141
|
+
execute(entity: Entity): Promise<Result<Entity, RepositoryError>>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const CreateEntityRepository = createAbstraction<ICreateEntityRepository>(
|
|
145
|
+
"MyExt/CreateEntityRepository"
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
export namespace CreateEntityRepository {
|
|
149
|
+
export type Interface = ICreateEntityRepository;
|
|
150
|
+
export type Error = RepositoryError;
|
|
151
|
+
export type Return = Promise<Result<Entity, RepositoryError>>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// USE CASE errors — superset of repository errors
|
|
155
|
+
export interface ICreateEntityUseCaseErrors {
|
|
156
|
+
persistence: EntityPersistenceError;
|
|
157
|
+
modelNotFound: EntityModelNotFoundError;
|
|
158
|
+
creation: EntityCreationError;
|
|
159
|
+
notAuthorized: NotAuthorizedError;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type UseCaseError = ICreateEntityUseCaseErrors[keyof ICreateEntityUseCaseErrors];
|
|
163
|
+
|
|
164
|
+
export interface ICreateEntityUseCase {
|
|
165
|
+
execute(input: CreateEntityInput): Promise<Result<Entity, UseCaseError>>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const CreateEntityUseCase = createAbstraction<ICreateEntityUseCase>(
|
|
169
|
+
"MyExt/CreateEntityUseCase"
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
export namespace CreateEntityUseCase {
|
|
173
|
+
export type Interface = ICreateEntityUseCase;
|
|
174
|
+
export type Input = CreateEntityInput;
|
|
175
|
+
export type Error = UseCaseError;
|
|
176
|
+
export type Return = Promise<Result<Entity, UseCaseError>>;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Result Pattern
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// Success
|
|
184
|
+
return Result.ok(value);
|
|
185
|
+
|
|
186
|
+
// Failure
|
|
187
|
+
return Result.fail(new EntityNotFoundError(id));
|
|
188
|
+
|
|
189
|
+
// Check result
|
|
190
|
+
if (result.isFail()) {
|
|
191
|
+
return Result.fail(result.error);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Access value
|
|
195
|
+
const value = result.value;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Never use `result.isError()`, `result.getError()`, or `result.getValue()` — these do not exist.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## UseCase Implementation
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// features/createEntity/CreateEntityUseCase.ts
|
|
206
|
+
import { CreateEntityUseCase as UseCaseAbstraction, CreateEntityRepository } from "./abstractions.js";
|
|
207
|
+
import { Result } from "@webiny/feature/api";
|
|
208
|
+
import { IdentityContext } from "@webiny/api-core/exports/api/security.js";
|
|
209
|
+
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
|
|
210
|
+
import { Entity } from "~/shared/Entity.js";
|
|
211
|
+
import { EntityId } from "~/api/domain/EntityId.js";
|
|
212
|
+
|
|
213
|
+
class CreateEntityUseCase implements UseCaseAbstraction.Interface {
|
|
214
|
+
constructor(
|
|
215
|
+
private identityContext: IdentityContext.Interface,
|
|
216
|
+
private repository: CreateEntityRepository.Interface
|
|
217
|
+
) {}
|
|
218
|
+
|
|
219
|
+
async execute(input: UseCaseAbstraction.Input): UseCaseAbstraction.Return {
|
|
220
|
+
if (!this.identityContext.getPermission("mypackage.entity")) {
|
|
221
|
+
return Result.fail(new NotAuthorizedError({ message: "Not authorized to create entities!" }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const entity = Entity.from({
|
|
225
|
+
id: EntityId.from(input.id),
|
|
226
|
+
values: { name: input.name, status: "disabled" }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = await this.repository.execute(entity);
|
|
230
|
+
if (result.isFail()) {
|
|
231
|
+
return Result.fail(result.error);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Result.ok(result.value);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default UseCaseAbstraction.createImplementation({
|
|
239
|
+
implementation: CreateEntityUseCase,
|
|
240
|
+
dependencies: [IdentityContext, CreateEntityRepository]
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Rules:**
|
|
245
|
+
- Class implements `UseCaseAbstraction.Interface`
|
|
246
|
+
- Constructor params typed with `.Interface` from their abstractions
|
|
247
|
+
- Return type uses `UseCaseAbstraction.Return`
|
|
248
|
+
- `dependencies` array matches constructor parameter order exactly
|
|
249
|
+
- Export as `default`
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## CMS Repository Pattern
|
|
254
|
+
|
|
255
|
+
Repositories use CMS use cases to persist data. Always resolve the CMS model first.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// features/createEntity/CreateEntityRepository.ts
|
|
259
|
+
import { Entity } from "~/shared/Entity.js";
|
|
260
|
+
import { EntityCreationError, EntityModelNotFoundError } from "~/api/domain/errors.js";
|
|
261
|
+
import { CreateEntityRepository as RepositoryAbstraction } from "./abstractions.js";
|
|
262
|
+
import { Result } from "@webiny/feature/api";
|
|
263
|
+
import { CreateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
264
|
+
import { GetModelUseCase } from "@webiny/api-headless-cms/exports/api/cms/model";
|
|
265
|
+
import { ENTITY_MODEL_ID } from "~/shared/constants.js";
|
|
266
|
+
|
|
267
|
+
class CreateEntityRepository implements RepositoryAbstraction.Interface {
|
|
268
|
+
constructor(
|
|
269
|
+
private getModelUseCase: GetModelUseCase.Interface,
|
|
270
|
+
private createEntryUseCase: CreateEntryUseCase.Interface
|
|
271
|
+
) {}
|
|
272
|
+
|
|
273
|
+
async execute(entity: Entity): RepositoryAbstraction.Return {
|
|
274
|
+
const modelResult = await this.getModelUseCase.execute(ENTITY_MODEL_ID);
|
|
275
|
+
if (modelResult.isFail()) {
|
|
276
|
+
return Result.fail(new EntityModelNotFoundError());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const createResult = await this.createEntryUseCase.execute(modelResult.value, {
|
|
280
|
+
id: entity.id,
|
|
281
|
+
values: {
|
|
282
|
+
name: entity.values.name,
|
|
283
|
+
status: entity.values.status
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (createResult.isFail()) {
|
|
288
|
+
return Result.fail(new EntityCreationError(createResult.error));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return Result.ok(entity);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export default RepositoryAbstraction.createImplementation({
|
|
296
|
+
implementation: CreateEntityRepository,
|
|
297
|
+
dependencies: [GetModelUseCase, CreateEntryUseCase]
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Common CMS Use Cases for Repositories
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import { CreateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
305
|
+
import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
306
|
+
import { GetEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
307
|
+
import { UpdateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
308
|
+
import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
309
|
+
import { EntryId } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
|
|
310
|
+
import { GetModelUseCase } from "@webiny/api-headless-cms/exports/api/cms/model";
|
|
311
|
+
import { ListModelsUseCase } from "@webiny/api-headless-cms/exports/api/cms/model.js";
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Rules:**
|
|
315
|
+
- Always resolve the CMS model first via `GetModelUseCase`
|
|
316
|
+
- Wrap CMS errors in domain-specific errors
|
|
317
|
+
- Register repositories in **singleton scope**
|
|
318
|
+
- Export as `default`
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Entry-to-Entity Mapper
|
|
323
|
+
|
|
324
|
+
When repositories return CMS entries, use a mapper to convert to domain types:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// features/shared/EntryToEntityMapper.ts
|
|
328
|
+
import { Entity as EntityClass } from "~/shared/Entity.js";
|
|
329
|
+
import type { Entity, EntityDto, EntityValues } from "~/shared/Entity.js";
|
|
330
|
+
|
|
331
|
+
export class EntryToEntityMapper {
|
|
332
|
+
static toEntity(entry: { entryId: string; values: EntityValues }): Entity {
|
|
333
|
+
return EntityClass.from({
|
|
334
|
+
id: entry.entryId,
|
|
335
|
+
values: entry.values
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- Static methods only — no instance state
|
|
342
|
+
- Used by repositories, not by use cases directly
|
|
343
|
+
- Handle null/undefined values with defaults where appropriate
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## UseCase Decorators
|
|
348
|
+
|
|
349
|
+
Decorators add cross-cutting concerns (authorization, logging, validation) without modifying the core use case.
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// features/getEntityById/decorators/GetEntityByIdWithAuthorization.ts
|
|
353
|
+
import { GetEntityByIdUseCase } from "../abstractions.js";
|
|
354
|
+
import { Result } from "@webiny/feature/api";
|
|
355
|
+
import { IdentityContext } from "@webiny/api-core/exports/api/security.js";
|
|
356
|
+
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
|
|
357
|
+
|
|
358
|
+
class GetEntityByIdWithAuthorizationImpl implements GetEntityByIdUseCase.Interface {
|
|
359
|
+
constructor(
|
|
360
|
+
private identityContext: IdentityContext.Interface,
|
|
361
|
+
private decoratee: GetEntityByIdUseCase.Interface // decoratee is LAST
|
|
362
|
+
) {}
|
|
363
|
+
|
|
364
|
+
async execute(id: string): GetEntityByIdUseCase.Return {
|
|
365
|
+
if (!this.identityContext.getPermission("mypackage.entity")) {
|
|
366
|
+
return Result.fail(new NotAuthorizedError());
|
|
367
|
+
}
|
|
368
|
+
return this.decoratee.execute(id);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const GetEntityByIdWithAuthorization = GetEntityByIdUseCase.createDecorator({
|
|
373
|
+
decorator: GetEntityByIdWithAuthorizationImpl,
|
|
374
|
+
dependencies: [IdentityContext] // does NOT include decoratee
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Registering a Decorator
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// features/getEntityById/feature.ts
|
|
382
|
+
import { createFeature } from "@webiny/feature/api";
|
|
383
|
+
import GetEntityByIdUseCase from "./GetEntityByIdUseCase.js";
|
|
384
|
+
import GetEntityByIdRepository from "./GetEntityByIdRepository.js";
|
|
385
|
+
import { GetEntityByIdWithAuthorization } from "./decorators/GetEntityByIdWithAuthorization.js";
|
|
386
|
+
|
|
387
|
+
export const GetEntityByIdFeature = createFeature({
|
|
388
|
+
name: "GetEntityById",
|
|
389
|
+
register(container) {
|
|
390
|
+
container.register(GetEntityByIdUseCase);
|
|
391
|
+
container.register(GetEntityByIdRepository).inSingletonScope();
|
|
392
|
+
container.registerDecorator(GetEntityByIdWithAuthorization);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Rules:**
|
|
398
|
+
- Implements the same interface as the use case it decorates
|
|
399
|
+
- Constructor: extra dependencies first, `decoratee` **last**
|
|
400
|
+
- Use `UseCaseAbstraction.createDecorator(...)` — the `dependencies` array does NOT include the decoratee
|
|
401
|
+
- Register with `container.registerDecorator()`, not `container.register()`
|
|
402
|
+
- Can modify input before delegating, output after, or short-circuit with an error
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Schema-Based Permissions
|
|
407
|
+
|
|
408
|
+
For implementing authorization in use cases, see the **webiny-api-permissions** skill. It covers:
|
|
409
|
+
- Permission schema definition with `createPermissions`
|
|
410
|
+
- All permission methods (`canRead`, `canEdit`, `canDelete`, `canPublish`, `onlyOwnRecords`, etc.)
|
|
411
|
+
- Use case patterns for every CRUD operation (get, list, update, delete, publish)
|
|
412
|
+
- Own-record scoping and item-level ownership checks
|
|
413
|
+
- Testing patterns and permission object shapes
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
88
417
|
## Resolving Types (MANDATORY)
|
|
89
418
|
|
|
90
419
|
**Before writing any code that calls a UseCase or accesses its return types, you MUST read the source file listed in the catalog's `Source` field to verify the exact method signatures, input parameters, return types, and error types. Do not assume or guess property names from memory.**
|
|
91
420
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
2. If the interface references domain types (e.g., `CmsEntry`, `CmsModel`), follow the import and read that type declaration too.
|
|
96
|
-
3. Only use properties and method signatures that are confirmed to exist in the source type declarations.
|
|
421
|
+
1. Read the `abstractions.ts` file from the catalog `Source` path
|
|
422
|
+
2. If the interface references domain types, follow the import and read that type declaration
|
|
423
|
+
3. Only use properties and method signatures confirmed in the source
|
|
97
424
|
|
|
98
425
|
## Key Rules
|
|
99
426
|
|
|
100
|
-
- Always check `result.
|
|
427
|
+
- Always check `result.isFail()` before accessing `.value` or `.error`
|
|
101
428
|
- DI constructor parameter order must match the `dependencies` array order exactly
|
|
102
429
|
- Use `.js` extensions in import paths (ES modules)
|
|
430
|
+
|
|
431
|
+
## Related Skills
|
|
432
|
+
|
|
433
|
+
- **webiny-api-architect** — Architecture overview, Services vs UseCases, feature naming, anti-patterns
|
|
434
|
+
- **webiny-api-permissions** — Schema-based permissions, CRUD authorization patterns, testing
|
|
435
|
+
- **webiny-event-handler-pattern** — EventHandler lifecycle, domain event publishing
|
|
436
|
+
- **webiny-custom-graphql-api** — GraphQL schema creation with UseCase DI
|
|
437
|
+
- **webiny-dependency-injection** — Injectable services catalog
|