cabloy 5.1.154 → 5.1.156
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/.cabloy-version +1 -1
- package/.claude/scheduled_tasks.lock +1 -0
- package/.claude/skills/cabloy-contract-loop/SKILL.md +1 -1
- package/.claude/skills/cabloy-spec-generation/SKILL.md +1 -1
- package/.claude/skills/cabloy-workflow/SKILL.md +3 -3
- package/CHANGELOG.md +19 -0
- package/CLAUDE.md +1 -1
- package/README.md +18 -18
- package/package.json +1 -1
- package/repo-docs/.vitepress/public/CNAME +1 -1
- package/repo-docs/ai/edition-consistency-checklist.md +7 -7
- package/repo-docs/backend/quickstart.md +1 -1
- package/repo-docs/blogs/ai-react-nextjs-enterprise-architecture-cabloy/index.md +8 -8
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +13 -13
- package/repo-docs/blogs/vue-object-oriented-zova-beginner-mental-model/index.md +6 -6
- package/repo-docs/editions/cabloy-start.md +10 -12
- package/repo-docs/editions/choosing-between-basic-and-start.md +6 -7
- package/repo-docs/editions/detection.md +1 -1
- package/repo-docs/editions/overview.md +26 -14
- package/repo-docs/frontend/foundation.md +1 -1
- package/repo-docs/frontend/introduction.md +1 -1
- package/repo-docs/frontend/scripts.md +2 -2
- package/repo-docs/fullstack/deploy-cloudflare-docker.md +1 -1
- package/repo-docs/fullstack/edition-collaboration-differences.md +2 -2
- package/repo-docs/fullstack/introduction.md +7 -3
- package/repo-docs/fullstack/openapi-to-sdk.md +1 -1
- package/repo-docs/fullstack/quickstart.md +4 -4
- package/repo-docs/fullstack/vona-zova-integration.md +1 -1
- package/repo-docs/index.md +1 -1
- package/repo-docs/reference/glossary.md +8 -5
- package/repo-docs/reference/introduction.md +1 -1
- package/repo-docs/reference/repo-scripts.md +12 -4
- package/test-results/.last-run.json +2 -4
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-shared/package.json +4 -4
- package/vona/pnpm-lock.yaml +8 -3
- package/vona/src/suite/a-home/modules/home-user/src/bean/meta.redlock.ts +2 -1
- package/vona/src/suite/a-home/modules/home-user/src/service/roleAdapter.ts +115 -6
- package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +163 -2
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/index.ts +26 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/eventListener.roleMembershipChanged.ts +25 -0
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/src/lib/beanQueueBase.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/src/service/queue.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/.metadata/index.ts +15 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.role.ts +44 -3
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/event.roleMembershipChanged.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +12 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/pnpm-lock.yaml +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourceRouteMeta.ts +9 -0
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +4 -10
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/lib/routes.test.ts +37 -0
- package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
- package/test-results/cabloy-basic-ATP-BASIC-SUM-58d8a-ry-states-and-Markdown-HTML/error-context.md +0 -195
|
@@ -26,7 +26,7 @@ The most important differences show up in:
|
|
|
26
26
|
- frontend flavor names
|
|
27
27
|
- frontend module composition
|
|
28
28
|
- admin/web SSR site baselines
|
|
29
|
-
-
|
|
29
|
+
- Start-specific value-add content and project assets
|
|
30
30
|
- potentially different generated output paths or integration details
|
|
31
31
|
|
|
32
32
|
## Cabloy Basic
|
|
@@ -38,7 +38,7 @@ In the current public monorepo, Basic examples commonly align with:
|
|
|
38
38
|
|
|
39
39
|
## Cabloy Start
|
|
40
40
|
|
|
41
|
-
In the sibling
|
|
41
|
+
In the sibling public Start repository, examples commonly align with:
|
|
42
42
|
|
|
43
43
|
- Vuetify-oriented frontend assumptions
|
|
44
44
|
- Start-specific Zova flavors such as `cabloyStartAdmin` and `cabloyStartWeb`
|
|
@@ -61,9 +61,13 @@ Use this path when the task depends on edition boundaries, UI assumptions, or cr
|
|
|
61
61
|
|
|
62
62
|
## Shared architecture
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
-
|
|
64
|
+
Cabloy coordinates its backend and frontend layers as one fullstack system:
|
|
65
|
+
|
|
66
|
+
- **Vona** provides the backend framework and runtime capabilities.
|
|
67
|
+
- **Zova** provides the frontend framework and application capabilities.
|
|
68
|
+
- Root scripts, shared terminology, and CLI-first workflows keep the layers aligned within each edition repository.
|
|
69
|
+
|
|
70
|
+
Cabloy Basic and Cabloy Start are related, complete edition baselines built on this shared architecture, not alternatives to the Vona and Zova layers. They intentionally compose UI, flavors, modules, SSR sites, and project assets differently. See [Editions Overview](/editions/overview) for the complete relationship and edition differences.
|
|
67
71
|
|
|
68
72
|
This combination keeps backend and frontend development close enough for code sharing, workflow reuse, and AI vibe coding workflows.
|
|
69
73
|
|
|
@@ -204,7 +204,7 @@ If the endpoint still belongs to an existing business resource, prefer keeping `
|
|
|
204
204
|
|
|
205
205
|
## Cabloy Start workflow
|
|
206
206
|
|
|
207
|
-
In the sibling
|
|
207
|
+
In the sibling public Start repository, the same collaboration idea applies, but the frontend flavor names differ.
|
|
208
208
|
|
|
209
209
|
Representative Start-specific flavors include:
|
|
210
210
|
|
|
@@ -73,9 +73,9 @@ npm run dev:zova:web # http://localhost:9000/
|
|
|
73
73
|
|
|
74
74
|
### Cabloy Start
|
|
75
75
|
|
|
76
|
-
Cabloy Start is the
|
|
76
|
+
Cabloy Start is the public MIT-licensed edition maintained in its own repository. Instead of `npm create cabloy`, clone its public repository directly and run `npm run init`. Then use the frontend commands provided by that edition. Do not assume the Cabloy Basic flavor names apply to Cabloy Start.
|
|
77
77
|
|
|
78
|
-
For the full Start onboarding
|
|
78
|
+
For the full Start onboarding and initialization flow, read [Cabloy Start](/editions/cabloy-start).
|
|
79
79
|
|
|
80
80
|
If you are not sure which edition you are using or which one to choose, read:
|
|
81
81
|
|
|
@@ -123,7 +123,7 @@ Keep additional project-owned browser specs under other filenames in `repo-e2e/s
|
|
|
123
123
|
|
|
124
124
|
### Cabloy Start repository baseline
|
|
125
125
|
|
|
126
|
-
Cabloy Start is
|
|
126
|
+
Cabloy Start is cloned from its separate public repository rather than created through `npm create cabloy`. Its E2E baseline is maintained in that repository:
|
|
127
127
|
|
|
128
128
|
```text
|
|
129
129
|
repo-e2e/config/
|
|
@@ -131,7 +131,7 @@ repo-e2e/scripts/
|
|
|
131
131
|
repo-e2e/specs/
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
The public Cabloy upgrade flow does not synchronize or repair those
|
|
134
|
+
The public Cabloy upgrade flow does not synchronize or repair those Start-owned E2E files, its root E2E scripts, or `@playwright/test`. Keep project-owned browser scenarios in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
|
|
135
135
|
|
|
136
136
|
Prepare and run the managed Start baseline locally with:
|
|
137
137
|
|
|
@@ -51,7 +51,7 @@ cd zova && npm run build:rest:cabloyBasicWeb
|
|
|
51
51
|
|
|
52
52
|
## Cabloy Start
|
|
53
53
|
|
|
54
|
-
Cabloy Start is the
|
|
54
|
+
Cabloy Start is the public MIT-licensed edition. It lives in a separate sibling repository rather than a subdirectory here, but the same integration idea applies.
|
|
55
55
|
|
|
56
56
|
Its root script surface uses Start-specific flavors such as:
|
|
57
57
|
|
package/repo-docs/index.md
CHANGED
|
@@ -35,7 +35,7 @@ features:
|
|
|
35
35
|
|
|
36
36
|
Cabloy is a Node.js fullstack framework for AI vibe coding. It brings backend and frontend work into one system, with bidirectional type sync, CLI-first workflows, and source-grounded docs and skills for people and AI agents.
|
|
37
37
|
|
|
38
|
-
Start with the [Contract Loop Playbook](/fullstack/contract-loop-playbook) to understand bidirectional type sync, then use the [Fullstack CLI](/fullstack/cli) and [AI Development](/ai/introduction) guides to follow the current repository. Vona
|
|
38
|
+
Start with the [Contract Loop Playbook](/fullstack/contract-loop-playbook) to understand bidirectional type sync, then use the [Fullstack CLI](/fullstack/cli) and [AI Development](/ai/introduction) guides to follow the current repository. Cabloy coordinates Vona and Zova as its backend and frontend layers, while Cabloy Basic and Cabloy Start are related, complete edition baselines built on that shared architecture. See [Editions Overview](/editions/overview) for the relationship and deliberate edition differences.
|
|
39
39
|
|
|
40
40
|
## What you can do here
|
|
41
41
|
|
|
@@ -2,23 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Cabloy
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The coordinated Node.js fullstack system that combines backend and frontend workflows.
|
|
6
6
|
|
|
7
7
|
## Vona
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Cabloy's backend framework and runtime layer.
|
|
10
10
|
|
|
11
11
|
## Zova
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Cabloy's frontend framework and application layer.
|
|
14
14
|
|
|
15
15
|
## Cabloy Basic
|
|
16
16
|
|
|
17
|
-
The public
|
|
17
|
+
The public reference and community edition baseline for the shared Cabloy architecture, created with `npm create cabloy`.
|
|
18
18
|
|
|
19
19
|
## Cabloy Start
|
|
20
20
|
|
|
21
|
-
The
|
|
21
|
+
The public MIT-licensed business-system edition baseline for the shared Cabloy architecture, maintained in a sibling repository and initialized with `npm run init` after cloning.
|
|
22
|
+
|
|
23
|
+
Vona and Zova describe architectural layers; Cabloy Basic and Cabloy Start describe complete edition baselines built with those layers.
|
|
22
24
|
|
|
23
25
|
## Edition detection
|
|
24
26
|
|
|
@@ -35,4 +37,5 @@ Use the glossary together with:
|
|
|
35
37
|
- [Fullstack Introduction](/fullstack/introduction)
|
|
36
38
|
- [Backend (Vona)](/backend/introduction)
|
|
37
39
|
- [Frontend (Zova)](/frontend/introduction)
|
|
40
|
+
- [Editions Overview](/editions/overview)
|
|
38
41
|
- [Package Map](/reference/package-map)
|
|
@@ -42,6 +42,6 @@ Use this path when you need to find where packages, backend directories, fronten
|
|
|
42
42
|
|
|
43
43
|
## Edition impact
|
|
44
44
|
|
|
45
|
-
Cabloy Start keeps the same high-level workflow pattern while using different frontend flavors such as `cabloyStartAdmin` and `cabloyStartWeb`, plus its own SSR site baselines and project assets in
|
|
45
|
+
Cabloy Start keeps the same high-level workflow pattern while using different frontend flavors such as `cabloyStartAdmin` and `cabloyStartWeb`, plus its own SSR site baselines and project assets in its separate public repository.
|
|
46
46
|
|
|
47
47
|
When documenting or automating flavor-specific commands, always confirm the active repo first.
|
|
@@ -4,7 +4,15 @@ Use this page as the compact lookup surface for root scripts in Cabloy Basic and
|
|
|
4
4
|
|
|
5
5
|
For the broader Reference landing page, see [Reference Introduction](/reference/introduction).
|
|
6
6
|
|
|
7
|
-
Always start with the active repository's root `package.json`. Cabloy Basic is the public generated-project baseline; Cabloy Start is the licensed
|
|
7
|
+
Always start with the active repository's root `package.json`. Cabloy Basic is the public generated-project baseline; Cabloy Start is the public MIT-licensed edition in its own repository and has its own root command surface.
|
|
8
|
+
|
|
9
|
+
## Generated workspace manifests
|
|
10
|
+
|
|
11
|
+
The tracked root `package.json` is the primary command surface. The tracked `vona/package.original.json` and `zova/package.original.json` files are durable bootstrap manifest inputs. Their sibling `vona/package.json` and `zova/package.json` files are ignored generated working dependency closures, not durable edit targets.
|
|
12
|
+
|
|
13
|
+
Root `npm run init` runs `scripts/init.ts`, which restores each generated workspace manifest from its `package.original.json` input before running dependency generation. During Vona initialization, it also seeds generated `.zova-rest` workspace dependencies before the first install and dependency-tool run. Direct edits to either ignored workspace manifest are overwritten by the next root initialization.
|
|
14
|
+
|
|
15
|
+
Run root `npm run init` deliberately after a fresh clone, recovery of generated workspace state, or a framework upgrade. Do not treat it as an automatic follow-up for narrow changes.
|
|
8
16
|
|
|
9
17
|
## Cabloy Basic entrypoints
|
|
10
18
|
|
|
@@ -112,9 +120,9 @@ repo-e2e/specs/a-commerce.spec.ts
|
|
|
112
120
|
|
|
113
121
|
It also reconciles the two framework E2E scripts and the `@playwright/test` development dependency. Keep additional project browser specs under other filenames in `repo-e2e/specs`; the upgrader updates only the listed framework files. The current fresh baseline is required and is not repaired for unsupported legacy project layouts.
|
|
114
122
|
|
|
115
|
-
### Cabloy Start
|
|
123
|
+
### Cabloy Start repository
|
|
116
124
|
|
|
117
|
-
The Start E2E baseline is maintained in the
|
|
125
|
+
The Start E2E baseline is maintained in the separate Start repository:
|
|
118
126
|
|
|
119
127
|
```text
|
|
120
128
|
repo-e2e/config/
|
|
@@ -122,7 +130,7 @@ repo-e2e/scripts/
|
|
|
122
130
|
repo-e2e/specs/
|
|
123
131
|
```
|
|
124
132
|
|
|
125
|
-
The public-package upgrade flow does not source or reconcile the
|
|
133
|
+
The public-package upgrade flow does not source or reconcile the Start-owned baseline, its root E2E scripts, or `@playwright/test`. Keep project browser tests in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
|
|
126
134
|
|
|
127
135
|
## SSR browser checks
|
|
128
136
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.90",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-shared",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"keywords": [
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
"vona",
|
|
9
9
|
"vue3"
|
|
10
10
|
],
|
|
11
|
-
"homepage": "https://github.com/
|
|
11
|
+
"homepage": "https://github.com/vonajs/vona#readme",
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/vonajs/vona/issues"
|
|
14
14
|
},
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"author": "zhennann",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/
|
|
19
|
+
"url": "git+https://github.com/vonajs/vona.git"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"dist"
|
package/vona/pnpm-lock.yaml
CHANGED
|
@@ -615,8 +615,8 @@ importers:
|
|
|
615
615
|
specifier: ^5.0.5
|
|
616
616
|
version: 5.0.5
|
|
617
617
|
zova-module-rest-resource:
|
|
618
|
-
specifier: ^5.1.
|
|
619
|
-
version: 5.1.
|
|
618
|
+
specifier: ^5.1.48
|
|
619
|
+
version: 5.1.49
|
|
620
620
|
|
|
621
621
|
.zova-rest/cabloy-basic-web:
|
|
622
622
|
dependencies:
|
|
@@ -1610,7 +1610,7 @@ importers:
|
|
|
1610
1610
|
specifier: ^5.0.3
|
|
1611
1611
|
version: link:modules/a-markdown
|
|
1612
1612
|
vona-module-a-rbac:
|
|
1613
|
-
specifier: ^5.0.
|
|
1613
|
+
specifier: ^5.0.2
|
|
1614
1614
|
version: link:modules/a-rbac
|
|
1615
1615
|
vona-module-a-socket:
|
|
1616
1616
|
specifier: workspace:^
|
|
@@ -9052,6 +9052,9 @@ packages:
|
|
|
9052
9052
|
zova-module-rest-resource@5.1.47:
|
|
9053
9053
|
resolution: {integrity: sha512-/LBZxfD/ULG+sQVuGnC+aqRe32O4qJesaKZF5IHNCuhuRwLzqvUIpijoyA890R7Ah5kHGxcnqaScW8DYkmf+RA==}
|
|
9054
9054
|
|
|
9055
|
+
zova-module-rest-resource@5.1.49:
|
|
9056
|
+
resolution: {integrity: sha512-tLf3BLov4GnV1QPivvLro6T2sNdriDZ8vZMsghuUN7dl+PRCG2pI0q9hBCrqe2faRSIcVUXowwnEbEynmaM7Zg==}
|
|
9057
|
+
|
|
9055
9058
|
zova-suite-a-zova@5.1.152:
|
|
9056
9059
|
resolution: {integrity: sha512-P/dcMb2fcMpnmDMlrgvpddWUbupnvohD1Jp60VYWoqI0JcWMlYTJvWQWrwszDqiMvMiOTH5mmQBTws0KzbsXpA==}
|
|
9057
9060
|
|
|
@@ -15541,6 +15544,8 @@ snapshots:
|
|
|
15541
15544
|
|
|
15542
15545
|
zova-module-rest-resource@5.1.47: {}
|
|
15543
15546
|
|
|
15547
|
+
zova-module-rest-resource@5.1.49: {}
|
|
15548
|
+
|
|
15544
15549
|
zova-suite-a-zova@5.1.152(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
|
|
15545
15550
|
dependencies:
|
|
15546
15551
|
zova-module-a-api: 5.1.22
|
|
@@ -8,7 +8,8 @@ export type TypeRedlockLockResource =
|
|
|
8
8
|
| `homeUser.account.passwordSetCandidate.${string}`
|
|
9
9
|
| `homeUser.account.passwordResetConsume.${string}`
|
|
10
10
|
| `homeUser.account.activationMutation.${string}`
|
|
11
|
-
| `homeUser.account.activationConsume.${string}
|
|
11
|
+
| `homeUser.account.activationConsume.${string}`
|
|
12
|
+
| `homeUser.roleMembership.${string}`;
|
|
12
13
|
export type TypeRedlockLockIsolateResource = never;
|
|
13
14
|
|
|
14
15
|
@Meta()
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { TableIdentity } from 'table-identity';
|
|
2
|
-
import type { IRole, IRoleAdapter } from 'vona-module-a-user';
|
|
2
|
+
import type { IRole, IRoleAdapter, IRoleMembershipReplaceResult } from 'vona-module-a-user';
|
|
3
3
|
|
|
4
4
|
import { BeanBase } from 'vona';
|
|
5
5
|
import { Service } from 'vona-module-a-bean';
|
|
6
|
+
import { Core } from 'vona-module-a-core';
|
|
6
7
|
|
|
7
8
|
@Service()
|
|
8
9
|
export class ServiceRoleAdapter extends BeanBase implements IRoleAdapter {
|
|
@@ -19,11 +20,119 @@ export class ServiceRoleAdapter extends BeanBase implements IRoleAdapter {
|
|
|
19
20
|
return user?.roles;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
async addUserId(id: TableIdentity, userId: TableIdentity): Promise<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
async addUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
|
|
24
|
+
return await this.withUserMembershipLock(
|
|
25
|
+
userId,
|
|
26
|
+
async () => await this.addUserIdInTransaction(id, userId),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async removeUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
|
|
31
|
+
return await this.withUserMembershipLock(
|
|
32
|
+
userId,
|
|
33
|
+
async () => await this.removeUserIdInTransaction(id, userId),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async replaceUserRoleIds(
|
|
38
|
+
userId: TableIdentity,
|
|
39
|
+
roleIds: TableIdentity[],
|
|
40
|
+
options?: { preserveRoleIds?: TableIdentity[] },
|
|
41
|
+
): Promise<IRoleMembershipReplaceResult> {
|
|
42
|
+
const requestedRoleIds = this.uniqueIds(roleIds);
|
|
43
|
+
const preserveRoleIds = this.uniqueIds(options?.preserveRoleIds ?? []);
|
|
44
|
+
return await this.withUserMembershipLock(
|
|
45
|
+
userId,
|
|
46
|
+
async () =>
|
|
47
|
+
await this.replaceUserRoleIdsInTransaction(userId, requestedRoleIds, preserveRoleIds),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Core.transaction()
|
|
52
|
+
private async addUserIdInTransaction(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
|
|
53
|
+
const existing = await this.scope.model.roleUser.get({ userId, roleId: id });
|
|
54
|
+
if (existing) return false;
|
|
55
|
+
await this.scope.model.roleUser.insert({ userId, roleId: id });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Core.transaction()
|
|
60
|
+
private async removeUserIdInTransaction(
|
|
61
|
+
id: TableIdentity,
|
|
62
|
+
userId: TableIdentity,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const memberships = await this.scope.model.roleUser.select({ where: { userId, roleId: id } });
|
|
65
|
+
if (!memberships.length) return false;
|
|
66
|
+
await this.scope.model.roleUser.deleteBulk(memberships.map(item => item.id));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Core.transaction()
|
|
71
|
+
private async replaceUserRoleIdsInTransaction(
|
|
72
|
+
userId: TableIdentity,
|
|
73
|
+
requestedRoleIds: TableIdentity[],
|
|
74
|
+
preserveRoleIds: TableIdentity[],
|
|
75
|
+
): Promise<IRoleMembershipReplaceResult> {
|
|
76
|
+
const memberships = await this.scope.model.roleUser.select({ where: { userId } });
|
|
77
|
+
const membershipsByRoleId = new Map<string, (typeof memberships)[number]>();
|
|
78
|
+
const duplicateMembershipIds: TableIdentity[] = [];
|
|
79
|
+
for (const membership of memberships) {
|
|
80
|
+
const key = String(membership.roleId);
|
|
81
|
+
if (membershipsByRoleId.has(key)) {
|
|
82
|
+
duplicateMembershipIds.push(membership.id);
|
|
83
|
+
} else {
|
|
84
|
+
membershipsByRoleId.set(key, membership);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const requestedRoleIdsByKey = this.idsByKey(requestedRoleIds);
|
|
89
|
+
const preserveRoleIdsByKey = this.idsByKey(preserveRoleIds);
|
|
90
|
+
const desiredRoleIdsByKey = new Map(requestedRoleIdsByKey);
|
|
91
|
+
for (const [key, roleId] of preserveRoleIdsByKey) {
|
|
92
|
+
if (membershipsByRoleId.has(key)) desiredRoleIdsByKey.set(key, roleId);
|
|
26
93
|
}
|
|
27
|
-
|
|
94
|
+
|
|
95
|
+
const removedRoleIds: TableIdentity[] = [];
|
|
96
|
+
const obsoleteMembershipIds = [...duplicateMembershipIds];
|
|
97
|
+
for (const [key, membership] of membershipsByRoleId) {
|
|
98
|
+
if (!desiredRoleIdsByKey.has(key)) {
|
|
99
|
+
obsoleteMembershipIds.push(membership.id);
|
|
100
|
+
removedRoleIds.push(membership.roleId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (obsoleteMembershipIds.length) {
|
|
104
|
+
await this.scope.model.roleUser.deleteBulk(obsoleteMembershipIds);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const addedRoleIds: TableIdentity[] = [];
|
|
108
|
+
for (const [key, roleId] of requestedRoleIdsByKey) {
|
|
109
|
+
if (!membershipsByRoleId.has(key)) {
|
|
110
|
+
await this.scope.model.roleUser.insert({ userId, roleId });
|
|
111
|
+
addedRoleIds.push(roleId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { addedRoleIds, removedRoleIds };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async withUserMembershipLock<RESULT>(
|
|
118
|
+
userId: TableIdentity,
|
|
119
|
+
operation: () => Promise<RESULT>,
|
|
120
|
+
): Promise<RESULT> {
|
|
121
|
+
return await this.scope.redlock.lock(
|
|
122
|
+
`homeUser.roleMembership.${encodeURIComponent(String(userId))}`,
|
|
123
|
+
operation,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private uniqueIds(ids: TableIdentity[]): TableIdentity[] {
|
|
128
|
+
return [...this.idsByKey(ids).entries()]
|
|
129
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
130
|
+
.map(([, id]) => id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private idsByKey(ids: TableIdentity[]): Map<string, TableIdentity> {
|
|
134
|
+
const values = new Map<string, TableIdentity>();
|
|
135
|
+
for (const id of ids) values.set(String(id), id);
|
|
136
|
+
return values;
|
|
28
137
|
}
|
|
29
138
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import { describe, it } from 'node:test';
|
|
2
|
+
import { describe, it, mock } from 'node:test';
|
|
3
3
|
import { app } from 'vona-mock';
|
|
4
4
|
|
|
5
|
-
describe('role.test.ts', () => {
|
|
5
|
+
describe('role.test.ts', { concurrency: false }, () => {
|
|
6
6
|
it('action:roleSeedAndPassport', async () => {
|
|
7
7
|
await app.bean.executor.mockCtx(async () => {
|
|
8
8
|
const scope = app.scope('home-user');
|
|
@@ -41,4 +41,165 @@ describe('role.test.ts', () => {
|
|
|
41
41
|
assert.equal(passportSystemAdmin?.builtin, true);
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it('bean:role emits only actual membership changes', async () => {
|
|
46
|
+
let userId: string | undefined;
|
|
47
|
+
let roleId: string | undefined;
|
|
48
|
+
const events: unknown[] = [];
|
|
49
|
+
const event = app.scope('a-user').event.roleMembershipChanged;
|
|
50
|
+
const originalEmit = event.emit.bind(event);
|
|
51
|
+
const emitMock = mock.method(event, 'emit', async data => {
|
|
52
|
+
events.push(data);
|
|
53
|
+
return await originalEmit(data);
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
await app.bean.executor.mockCtx(async () => {
|
|
57
|
+
const role = await app.scope('home-user').model.role.insert({
|
|
58
|
+
name: `home-user-role-${crypto.randomUUID()}`,
|
|
59
|
+
title: 'Home User Role',
|
|
60
|
+
siteIds: ['admin'],
|
|
61
|
+
builtin: false,
|
|
62
|
+
});
|
|
63
|
+
roleId = String(role.id);
|
|
64
|
+
const user = await app.bean.user.register({
|
|
65
|
+
name: `home-user-role-member-${crypto.randomUUID()}`,
|
|
66
|
+
});
|
|
67
|
+
userId = String(user.id);
|
|
68
|
+
|
|
69
|
+
assert.equal(await app.bean.role.addUserId(role.id, user.id), true);
|
|
70
|
+
assert.equal(await app.bean.role.addUserId(role.id, user.id), false);
|
|
71
|
+
|
|
72
|
+
const memberships = await app.scope('home-user').model.roleUser.select({
|
|
73
|
+
where: { userId: user.id, roleId: role.id },
|
|
74
|
+
});
|
|
75
|
+
assert.equal(memberships.length, 1);
|
|
76
|
+
assert.deepEqual(events, [{ userIds: [user.id], roleIds: [role.id] }]);
|
|
77
|
+
|
|
78
|
+
assert.equal(await app.bean.role.removeUserId(role.id, user.id), true);
|
|
79
|
+
assert.equal(await app.bean.role.removeUserId(role.id, user.id), false);
|
|
80
|
+
assert.deepEqual(events, [
|
|
81
|
+
{ userIds: [user.id], roleIds: [role.id] },
|
|
82
|
+
{ userIds: [user.id], roleIds: [role.id] },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
} finally {
|
|
86
|
+
try {
|
|
87
|
+
await app.bean.executor.mockCtx(async () => {
|
|
88
|
+
const homeUser = app.scope('home-user');
|
|
89
|
+
if (userId) {
|
|
90
|
+
await homeUser.model.roleUser.delete({ userId });
|
|
91
|
+
await app.bean.user.removeById(userId);
|
|
92
|
+
}
|
|
93
|
+
if (roleId) await homeUser.model.role.deleteById(roleId);
|
|
94
|
+
});
|
|
95
|
+
} finally {
|
|
96
|
+
emitMock.mock.restore();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('bean:role rolls back membership and policy invalidation together', async () => {
|
|
102
|
+
let userId: string | undefined;
|
|
103
|
+
let roleId: string | undefined;
|
|
104
|
+
try {
|
|
105
|
+
await app.bean.executor.mockCtx(async () => {
|
|
106
|
+
const homeUser = app.scope('home-user');
|
|
107
|
+
const role = await homeUser.model.role.insert({
|
|
108
|
+
name: `home-user-rollback-${crypto.randomUUID()}`,
|
|
109
|
+
title: 'Home User Rollback Role',
|
|
110
|
+
siteIds: ['admin'],
|
|
111
|
+
builtin: false,
|
|
112
|
+
});
|
|
113
|
+
roleId = String(role.id);
|
|
114
|
+
const user = await app.bean.user.register({
|
|
115
|
+
name: `home-user-role-rollback-${crypto.randomUUID()}`,
|
|
116
|
+
});
|
|
117
|
+
userId = String(user.id);
|
|
118
|
+
|
|
119
|
+
await assert.rejects(async () => {
|
|
120
|
+
await app.bean.database.current.transaction.begin(async () => {
|
|
121
|
+
await app.bean.role.addUserId(role.id, user.id);
|
|
122
|
+
throw new Error('rollback role membership');
|
|
123
|
+
});
|
|
124
|
+
}, /rollback role membership/);
|
|
125
|
+
|
|
126
|
+
assert.equal(
|
|
127
|
+
await homeUser.model.roleUser.get({ userId: user.id, roleId: role.id }),
|
|
128
|
+
undefined,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
await app.bean.executor.mockCtx(async () => {
|
|
133
|
+
const homeUser = app.scope('home-user');
|
|
134
|
+
if (userId) {
|
|
135
|
+
await homeUser.model.roleUser.delete({ userId });
|
|
136
|
+
await app.bean.user.removeById(userId);
|
|
137
|
+
}
|
|
138
|
+
if (roleId) await homeUser.model.role.deleteById(roleId);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('bean:role replaces a membership set with one change event', async () => {
|
|
144
|
+
let userId: string | undefined;
|
|
145
|
+
const roleIds: string[] = [];
|
|
146
|
+
const events: unknown[] = [];
|
|
147
|
+
const event = app.scope('a-user').event.roleMembershipChanged;
|
|
148
|
+
const originalEmit = event.emit.bind(event);
|
|
149
|
+
const emitMock = mock.method(event, 'emit', async data => {
|
|
150
|
+
events.push(data);
|
|
151
|
+
return await originalEmit(data);
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
await app.bean.executor.mockCtx(async () => {
|
|
155
|
+
const homeUser = app.scope('home-user');
|
|
156
|
+
const roles = await Promise.all(
|
|
157
|
+
['a', 'b', 'c'].map(async suffix => {
|
|
158
|
+
const role = await homeUser.model.role.insert({
|
|
159
|
+
name: `home-user-replace-${suffix}-${crypto.randomUUID()}`,
|
|
160
|
+
title: `Home User Role ${suffix}`,
|
|
161
|
+
siteIds: ['admin'],
|
|
162
|
+
builtin: false,
|
|
163
|
+
});
|
|
164
|
+
roleIds.push(String(role.id));
|
|
165
|
+
return role;
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const user = await app.bean.user.register({
|
|
169
|
+
name: `home-user-role-replace-${crypto.randomUUID()}`,
|
|
170
|
+
});
|
|
171
|
+
userId = String(user.id);
|
|
172
|
+
await app.bean.role.addUserId(roles[0].id, user.id);
|
|
173
|
+
await app.bean.role.addUserId(roles[1].id, user.id);
|
|
174
|
+
events.length = 0;
|
|
175
|
+
|
|
176
|
+
const result = await app.bean.role.replaceUserRoleIds(user.id, [roles[1].id, roles[2].id]);
|
|
177
|
+
assert.deepEqual(result, { addedRoleIds: [roles[2].id], removedRoleIds: [roles[0].id] });
|
|
178
|
+
assert.deepEqual(events, [{ userIds: [user.id], roleIds: [roles[0].id, roles[2].id] }]);
|
|
179
|
+
|
|
180
|
+
const memberships = await homeUser.model.roleUser.select({ where: { userId: user.id } });
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
memberships.map(item => String(item.roleId)).toSorted(),
|
|
183
|
+
[String(roles[1].id), String(roles[2].id)].toSorted(),
|
|
184
|
+
);
|
|
185
|
+
await app.bean.role.replaceUserRoleIds(user.id, [roles[1].id, roles[2].id]);
|
|
186
|
+
assert.equal(events.length, 1);
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
try {
|
|
190
|
+
await app.bean.executor.mockCtx(async () => {
|
|
191
|
+
const homeUser = app.scope('home-user');
|
|
192
|
+
if (userId) {
|
|
193
|
+
await homeUser.model.roleUser.delete({ userId });
|
|
194
|
+
await app.bean.user.removeById(userId);
|
|
195
|
+
}
|
|
196
|
+
for (const roleId of roleIds.reverse()) {
|
|
197
|
+
await homeUser.model.role.deleteById(roleId);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
} finally {
|
|
201
|
+
emitMock.mock.restore();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
44
205
|
});
|
|
@@ -109,6 +109,32 @@ declare module 'vona-module-a-event' {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
/** event: end */
|
|
112
|
+
/** eventListener: begin */
|
|
113
|
+
export * from '../bean/eventListener.roleMembershipChanged.ts';
|
|
114
|
+
|
|
115
|
+
import { type IDecoratorEventListenerOptions } from 'vona-module-a-event';
|
|
116
|
+
declare module 'vona-module-a-event' {
|
|
117
|
+
|
|
118
|
+
export interface IEventListenerRecord {
|
|
119
|
+
'a-rbac:roleMembershipChanged': IDecoratorEventListenerOptions;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
}
|
|
124
|
+
declare module 'vona-module-a-rbac' {
|
|
125
|
+
|
|
126
|
+
export interface EventListenerRoleMembershipChanged {
|
|
127
|
+
/** @internal */
|
|
128
|
+
get scope(): ScopeModuleARbac;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface EventListenerRoleMembershipChanged {
|
|
132
|
+
get $beanFullName(): 'a-rbac.eventListener.roleMembershipChanged';
|
|
133
|
+
get $onionName(): 'a-rbac:roleMembershipChanged';
|
|
134
|
+
get $onionOptions(): IDecoratorEventListenerOptions;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** eventListener: end */
|
|
112
138
|
/** config: begin */
|
|
113
139
|
export * from '../config/config.ts';
|
|
114
140
|
import type { config } from '../config/config.ts';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IEventExecute, NextEvent } from 'vona-module-a-event';
|
|
2
|
+
import type {
|
|
3
|
+
TypeEventRoleMembershipChangedData,
|
|
4
|
+
TypeEventRoleMembershipChangedResult,
|
|
5
|
+
} from 'vona-module-a-user';
|
|
6
|
+
|
|
7
|
+
import { BeanBase } from 'vona';
|
|
8
|
+
import { EventListener } from 'vona-module-a-event';
|
|
9
|
+
|
|
10
|
+
type TypeEventData = TypeEventRoleMembershipChangedData;
|
|
11
|
+
type TypeEventResult = TypeEventRoleMembershipChangedResult;
|
|
12
|
+
|
|
13
|
+
@EventListener({ match: 'a-user:roleMembershipChanged' })
|
|
14
|
+
export class EventListenerRoleMembershipChanged
|
|
15
|
+
extends BeanBase
|
|
16
|
+
implements IEventExecute<TypeEventData, TypeEventResult>
|
|
17
|
+
{
|
|
18
|
+
async execute(
|
|
19
|
+
data: TypeEventData,
|
|
20
|
+
next: NextEvent<TypeEventData, TypeEventResult>,
|
|
21
|
+
): Promise<TypeEventResult> {
|
|
22
|
+
await this.scope.event.policyInvalidated.emit({ kind: 'role' });
|
|
23
|
+
return await next(data);
|
|
24
|
+
}
|
|
25
|
+
}
|