cabloy 5.1.243 → 5.1.244
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/.github/workflows/vona-test-sqlite3.yml +1 -1
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +6 -6
- package/repo-agent-governance/policies/repository.md +1 -0
- package/repo-agent-governance/skills/cabloy-frontend-scaffold/SKILL.md +4 -2
- package/repo-docs/backend/auth-guide.md +48 -0
- package/repo-docs/backend/demonstration-mode-guide.md +22 -5
- package/repo-docs/backend/openapi-guide.md +1 -1
- package/repo-docs/frontend/behavior-guide.md +12 -0
- package/repo-e2e/specs/home-user-account.spec.ts +98 -3
- package/vona/env/.env +2 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +27 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +1 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +8 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +76 -1
- package/vona/src/suite/a-home/modules/home-user/test/passportSession.test.ts +380 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/decorator/mcp.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- 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/bean/bean.passport.ts +82 -20
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/config/config.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/lib/auth.ts +20 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/main.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/authTokenAdapter.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/redisToken.ts +6 -2
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/auth.ts +8 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/passport.ts +1 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +5 -4
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +5 -4
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.244
|
|
@@ -94,7 +94,7 @@ jobs:
|
|
|
94
94
|
run: npm run init
|
|
95
95
|
- name: run isolated test infrastructure gate
|
|
96
96
|
# Bound concurrent PowerShell/Add-Type cold starts on Windows runners.
|
|
97
|
-
run: node --test ${{ runner.os == 'Windows' && '--test-concurrency=2' || '' }} scripts/testClaimProcesses.test.ts scripts/runIsolatedTest.test.ts scripts/testClaimCleanup.test.ts scripts/testProcessGuardianWindows.test.ts vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts vona/packages-cli/cli-set-api/test/binBuildFreshInputs.test.ts vona/packages-cli/cli-set-api/test/binTestPatterns.test.ts
|
|
97
|
+
run: node --test ${{ runner.os == 'Windows' && '--test-concurrency=2' || '' }} scripts/testClaimProcesses.test.ts scripts/runIsolatedTest.test.ts scripts/testClaimCleanup.test.ts scripts/testProcessGuardianWindows.test.ts vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts vona/packages-cli/cli-set-api/test/binBuildFreshInputs.test.ts vona/packages-cli/cli-set-api/test/binTestPatterns.test.ts vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts
|
|
98
98
|
- name: run A-Pay focused gate
|
|
99
99
|
run: npm run test -- test-pay/test/paymentSession.test.ts test-pay/test/payMock.test.ts test-pay/test/paypalProvider.test.ts test-pay/test/paypalLifecycle.test.ts test-pay/test/stripeProvider.test.ts test-pay/test/stripeLifecycle.test.ts test-pay/test/outbox.test.ts test-pay/test/webhook.test.ts
|
|
100
100
|
working-directory: vona
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.1.244
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add `ZButton` and `AUTH_TOKEN_MODE`.
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fix Babel plugin compatibility on Windows.
|
|
12
|
+
|
|
13
|
+
### Improvements
|
|
14
|
+
|
|
15
|
+
- Refactor `mcp.tool` to `Mcp.tool`.
|
|
16
|
+
|
|
3
17
|
## 5.1.243
|
|
4
18
|
|
|
5
19
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
{
|
|
96
96
|
"adapter": "codex",
|
|
97
97
|
"category": "skill",
|
|
98
|
-
"sha256": "
|
|
98
|
+
"sha256": "fb81da315c743a45ced77d6e7c10be25f521d72de7f14d4e7b4a8037716b2b31",
|
|
99
99
|
"source": "skills/cabloy-frontend-scaffold/SKILL.md",
|
|
100
100
|
"target": ".agents/skills/cabloy-frontend-scaffold/SKILL.md"
|
|
101
101
|
},
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
{
|
|
432
432
|
"adapter": "claude",
|
|
433
433
|
"category": "skill",
|
|
434
|
-
"sha256": "
|
|
434
|
+
"sha256": "fb81da315c743a45ced77d6e7c10be25f521d72de7f14d4e7b4a8037716b2b31",
|
|
435
435
|
"source": "skills/cabloy-frontend-scaffold/SKILL.md",
|
|
436
436
|
"target": ".claude/skills/cabloy-frontend-scaffold/SKILL.md"
|
|
437
437
|
},
|
|
@@ -683,7 +683,7 @@
|
|
|
683
683
|
{
|
|
684
684
|
"adapter": "cursor",
|
|
685
685
|
"category": "policy",
|
|
686
|
-
"sha256": "
|
|
686
|
+
"sha256": "ad91c94c7dd07f24ef3580c991b34439721902413051843a3445ca903979f5a3",
|
|
687
687
|
"source": "policies/repository.md",
|
|
688
688
|
"target": ".cursor/rules/cabloy-governance.mdc"
|
|
689
689
|
},
|
|
@@ -781,7 +781,7 @@
|
|
|
781
781
|
{
|
|
782
782
|
"adapter": "cursor",
|
|
783
783
|
"category": "skill",
|
|
784
|
-
"sha256": "
|
|
784
|
+
"sha256": "fb81da315c743a45ced77d6e7c10be25f521d72de7f14d4e7b4a8037716b2b31",
|
|
785
785
|
"source": "skills/cabloy-frontend-scaffold/SKILL.md",
|
|
786
786
|
"target": ".cursor/skills/cabloy-frontend-scaffold/SKILL.md"
|
|
787
787
|
},
|
|
@@ -1005,14 +1005,14 @@
|
|
|
1005
1005
|
{
|
|
1006
1006
|
"adapter": "codex",
|
|
1007
1007
|
"category": "policy",
|
|
1008
|
-
"sha256": "
|
|
1008
|
+
"sha256": "deb64df2f792d1c28dec0727e1a70871c7a96445eceb8a65155977fc77cbde62",
|
|
1009
1009
|
"source": "policies/repository.md",
|
|
1010
1010
|
"target": "AGENTS.md"
|
|
1011
1011
|
},
|
|
1012
1012
|
{
|
|
1013
1013
|
"adapter": "claude",
|
|
1014
1014
|
"category": "policy",
|
|
1015
|
-
"sha256": "
|
|
1015
|
+
"sha256": "deb64df2f792d1c28dec0727e1a70871c7a96445eceb8a65155977fc77cbde62",
|
|
1016
1016
|
"source": "policies/repository.md",
|
|
1017
1017
|
"target": "CLAUDE.md"
|
|
1018
1018
|
}
|
|
@@ -59,6 +59,7 @@ Before inventing a custom implementation path:
|
|
|
59
59
|
- For Cabloy Start, apply the same reverse-chain logic but resolve the Start-specific flavor names and generated-output paths from the active Start repo before recommending commands.
|
|
60
60
|
- Treat legacy docs as input material, not as unquestioned truth. When docs conflict with source code, prefer current source code.
|
|
61
61
|
- For frontend work, assume Cabloy Basic and Cabloy Start share a frontend engineering layer but may diverge in UI layer, frontend flavors, suite/module availability, SSR site baselines, project assets, and generated outputs.
|
|
62
|
+
- In Cabloy Basic and Cabloy Start, prefer `ZButton` for button actions rather than rebuilding its loading, disabled, duplicate-click, and error handling with native elements. Return or await the complete async action through `onPerform`; use `mutateAsync()` rather than `mutate()` when the button owns mutation-error feedback. Keep navigation as links and use native buttons or lower-level behaviors only for a defined interaction need; preserve form-wide or shared lifecycle ownership. See [Behavior Guide](repo-docs/frontend/behavior-guide.md#perform-a-standalone-asynchronous-button-action).
|
|
62
63
|
- For Zova frontend analysis, do not default to generic Vue reinterpretation first. Read the code through Zova’s controller / bean / IoC architecture before mapping it to Vue concepts.
|
|
63
64
|
- For Zova source-reading or Vue-vs-Zova explanation tasks, start from the frontend reading guides and source-reading map in `repo-docs/frontend/` before doing framework-neutral reinterpretation.
|
|
64
65
|
- In Zova application TypeScript/TSX source, authored or generated, relative imports and exports must name the actual source target: `.ts` for `.ts` targets and `.tsx` for `.tsx` targets, including type-only imports, tests, frontend entry/config source, and generated metadata. CLI templates and generators must preserve this convention in generated application source; regenerate generated consumers rather than hand-editing them. Preserve the existing source-suffix convention in `zova/packages-utils/**` and `zova/packages-zova/**`. Keep bare package names, assets, real JavaScript targets, compiled runtime loaders, and package exports unchanged; do not apply a repository-wide suffix replacement or extend this application-source rule to Vona, dependencies, or build artifacts.
|
|
@@ -202,6 +202,8 @@ In that case, surface this operational sequence:
|
|
|
202
202
|
|
|
203
203
|
### Component and interaction follow-up
|
|
204
204
|
|
|
205
|
+
For both Cabloy Basic and Cabloy Start, prefer `ZButton` for button actions when creating or modifying an interaction, including existing menu actions. Do not reconstruct its action lifecycle with native elements by default. Keep navigation as links; use native buttons or lower-level behaviors only for a defined interaction need, and preserve form-wide or shared lifecycle ownership. Resolve the component import and styling from the active edition.
|
|
206
|
+
|
|
205
207
|
Check whether the feature needs:
|
|
206
208
|
|
|
207
209
|
- props contract review
|
|
@@ -209,7 +211,7 @@ Check whether the feature needs:
|
|
|
209
211
|
- generic component conversion
|
|
210
212
|
- style/theme/icon updates
|
|
211
213
|
- wrapper usage review
|
|
212
|
-
- async interaction ownership: for a button-only action, return or await the complete action through `ZButton onPerform`, choose one error presentation owner rather than combining its generic alert with local query/error UI, and do not mirror the same lifecycle with button-local `loading` / `disabled` state; retain explicit state only for independently initiated or broader shared work
|
|
214
|
+
- async interaction ownership: for a button-only action, return or await the complete action through `ZButton onPerform`; when the button owns mutation-error feedback, use `mutateAsync()` rather than `mutate()`, which consumes the rejection; choose one error presentation owner rather than combining its generic alert with local query/error UI, and do not mirror the same lifecycle with button-local `loading` / `disabled` state; retain explicit state only for independently initiated or broader shared work
|
|
213
215
|
- business row actions that require additional input: prefer a compact trigger and a focused dialog unless deliberate inline editing is intended; follow the [TableCell dialog pattern](../../../repo-docs/frontend/table-cell-cookbook.md#collect-input-for-a-business-row-action-in-a-dialog) for schema-backed forms where suitable, existing mutation ownership, success-only closure, failure input retention, and dialog-scoped E2E verification
|
|
214
216
|
- async-loading or controllerRef implications
|
|
215
217
|
|
|
@@ -224,7 +226,7 @@ Check whether the feature needs:
|
|
|
224
226
|
- SSR or route-path verification
|
|
225
227
|
- hydration-time initial-render equivalence when SSR, private state, browser-only state, or async model state changes
|
|
226
228
|
- edition-specific flavor, SSR site baseline, and project-asset verification
|
|
227
|
-
- interaction failure-path verification when `ZButton onPerform` owns a query action: a failed refetch reaches exactly the intended `onError`, generic alert, or local error UI, and button loading resets
|
|
229
|
+
- interaction failure-path verification when `ZButton onPerform` owns a query or mutation action: a failed refetch or mutation reaches exactly the intended `onError`, generic alert, or local error UI, and button loading resets
|
|
228
230
|
|
|
229
231
|
### SSR theme review reminder
|
|
230
232
|
|
|
@@ -131,6 +131,54 @@ The `home-user` module exposes out-of-the-box Passport APIs for common account f
|
|
|
131
131
|
|
|
132
132
|
In the current repo implementation, these flows live in `home-user/src/controller/passport.ts`, where register/login call `bean.auth.authenticate(...)`, OAuth login uses provider/module path parameters, and token-oriented follow-up endpoints stay concentrated in one controller surface.
|
|
133
133
|
|
|
134
|
+
## Auth-token mode
|
|
135
|
+
|
|
136
|
+
The auth-token mode selects how Vona issues, stores, reuses, and refreshes server-side authentication state. By default, logins for the same user and authentication record share one token identity. Applications that need independently managed login sessions can select `session` mode. Ordinary logout revocation follows the selected mode; this is not a logout-only setting.
|
|
137
|
+
|
|
138
|
+
Set the backend environment value to one of `auth` or `session`:
|
|
139
|
+
|
|
140
|
+
```dotenv
|
|
141
|
+
AUTH_TOKEN_MODE=session
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The default is `auth`. The equivalent typed project-config override is:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import type { VonaConfigOptional } from 'vona';
|
|
148
|
+
|
|
149
|
+
const config: VonaConfigOptional = {
|
|
150
|
+
modules: {
|
|
151
|
+
'a-user': {
|
|
152
|
+
authToken: {
|
|
153
|
+
mode: 'session',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The setting is `modules['a-user'].authToken.mode`. Backend code can read the validated effective value through the read-only `bean.passport.authTokenMode` getter. See [Config Guide](/backend/config-guide) for configuration layering.
|
|
161
|
+
|
|
162
|
+
### Authentication-record mode: `auth`
|
|
163
|
+
|
|
164
|
+
`auth` preserves the existing behavior: server-side tokens are stored by user and authentication record. With the default signin strategy, separate logins for that pair reuse the same token identity and extend its lifetime. Logout revokes their shared login state, rather than only the requesting browser's login. It does not mean every authentication method associated with the user is revoked.
|
|
165
|
+
|
|
166
|
+
### Login-session mode: `session`
|
|
167
|
+
|
|
168
|
+
`session` gives each successful login a fresh session identity in the existing auth-token `token` field. Logout revokes that login's server-side state without revoking independent logins using the same user and authentication record. Token refresh preserves the session identity; receiving a refreshed JWT does not create a new login session.
|
|
169
|
+
|
|
170
|
+
This is **per login, not per tab**. Tabs or clients that share the same credential share the same session and are signed out together. A separate login creates an independent session.
|
|
171
|
+
|
|
172
|
+
Session mode requires stateful auth tokens. It is incompatible with `authToken.stateless: true`, including provider/client overrides that select stateless tokens. The refresh strategy `authToken.strategy.refreshAuthToken: 'reissue'` is also unsupported in session mode: refresh must retain the existing session identity rather than replace it. Keep the default `refresh` strategy unless the application has a specific reason to use `reuse`.
|
|
173
|
+
|
|
174
|
+
The narrower revocation in session mode applies to ordinary logout, not account-wide security operations. Password changes, password resets, and initial password setup retain their broader security invalidation behavior; they do not become session-only operations.
|
|
175
|
+
|
|
176
|
+
### Switching modes
|
|
177
|
+
|
|
178
|
+
Treat a change between `auth` and `session` as a coordinated deployment change. All workers serving the same application must use the same effective mode; do not run mixed modes against shared token state. Restart the workers together and require users to log in again. Existing credentials are not transparently migrated between modes.
|
|
179
|
+
|
|
180
|
+
For shared demonstration deployments, session mode also lets ordinary visitors log out without revoking other visitors' independent logins. See [Demonstration Mode Guide](/backend/demonstration-mode-guide#logout-policy).
|
|
181
|
+
|
|
134
182
|
## Registration and password recovery in Cabloy Basic
|
|
135
183
|
|
|
136
184
|
The Home Login experience uses the Passport registration flow for new users. Registration collects the standard username, email, password, confirmation, and CAPTCHA fields, then continues through the ordinary signed-in navigation flow after it succeeds.
|
|
@@ -68,7 +68,7 @@ The Guard protects these HTTP methods:
|
|
|
68
68
|
- `DELETE`
|
|
69
69
|
- `PUT`
|
|
70
70
|
|
|
71
|
-
Other methods pass through this Guard unchanged.
|
|
71
|
+
Other methods pass through this Guard unchanged. Apart from the built-in authentication routes and the conditional [logout policy](#logout-policy), a protected request is allowed only for an eligible whitelisted user. All other protected requests are rejected with:
|
|
72
72
|
|
|
73
73
|
| Field | Value |
|
|
74
74
|
| ---------------------- | ------------------------------------------------- |
|
|
@@ -82,10 +82,6 @@ The Guard depends on Passport-resolved account state, so it makes this decision
|
|
|
82
82
|
|
|
83
83
|
Demonstration Mode deliberately does not apply to the standard authentication and OAuth routes below. This keeps sign-in, registration, account association and migration, OAuth callbacks, and token flows available while the write policy is active.
|
|
84
84
|
|
|
85
|
-
In Cabloy Basic, and Cabloy Start, `POST /home/user/passport/logout` is **not exempt**. While Demonstration Mode is enabled, only authenticated, active, non-anonymous whitelisted users may log out; other users receive `403` with `demo-demonstration:1001`.
|
|
86
|
-
|
|
87
|
-
This prevents ordinary visitors sharing a demo account from revoking other visitors' logins. Whitelisted users remain able to log out. The underlying logout semantics are unchanged: logout revokes the shared login state for the current account and authentication record, not just the current browser session. When Demonstration Mode is disabled, this Guard does not restrict logout.
|
|
88
|
-
|
|
89
85
|
```text
|
|
90
86
|
/home/user/passport/register
|
|
91
87
|
/home/user/passport/login
|
|
@@ -100,6 +96,27 @@ This prevents ordinary visitors sharing a demo account from revoking other visit
|
|
|
100
96
|
|
|
101
97
|
These are route templates used by the built-in authentication flows. They are not a general mechanism for bypassing Demonstration Mode on arbitrary URLs.
|
|
102
98
|
|
|
99
|
+
## Logout policy
|
|
100
|
+
|
|
101
|
+
Logout is deliberately absent from the unconditional authentication-route exemptions. While Demonstration Mode is enabled, its behavior depends on the validated effective `bean.passport.authTokenMode`:
|
|
102
|
+
|
|
103
|
+
- **`auth` (default):** `POST /home/user/passport/logout` remains a protected write. Only authenticated, active, non-anonymous whitelisted users may continue; other users receive `403` with `demo-demonstration:1001`. This prevents ordinary visitors sharing a demo account and authentication record from revoking other visitors' logins.
|
|
104
|
+
- **`session`:** the Guard lets `POST /home/user/passport/logout` continue regardless of whitelist membership because logout revokes only the requesting login session. Normal Passport authentication, account-state checks, and other authorization policies still apply. This is not an authentication bypass.
|
|
105
|
+
|
|
106
|
+
To enable independent logins in a shared demo deployment:
|
|
107
|
+
|
|
108
|
+
```dotenv
|
|
109
|
+
AUTH_TOKEN_MODE=session
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The supported values are `auth` and `session`; the default is `auth`. The equivalent typed project-config setting is `modules['a-user'].authToken.mode`.
|
|
113
|
+
|
|
114
|
+
Session mode creates a fresh identity for each login in the existing auth-token `token` field, and token refresh preserves that identity. Tabs sharing a credential share one session: logging out from one signs out the others. This is **per login, not per tab**.
|
|
115
|
+
|
|
116
|
+
Session mode requires stateful tokens and does not support the refresh strategy `reissue`. Password changes, password resets, and initial password setup retain their broader security invalidation behavior. Switching auth-token modes requires coordinated workers and a new login; do not mix modes across workers or expect existing credentials to migrate automatically. See [Auth-token mode](/backend/auth-guide#auth-token-mode) for typed configuration and deployment guidance.
|
|
117
|
+
|
|
118
|
+
The conditional allowance applies only to `POST` on the exact built-in route template `/home/user/passport/logout`. It does not exempt other write methods or business routes. When Demonstration Mode is disabled, this Guard does not restrict logout, but the configured auth-token mode still determines revocation behavior.
|
|
119
|
+
|
|
103
120
|
## Deploy with the `a-demo` suite
|
|
104
121
|
|
|
105
122
|
The `demo-demonstration` module is included in the current project's `a-demo` suite; no separate package installation is required.
|
|
@@ -50,7 +50,7 @@ This is important because OpenAPI is treated as a first-class framework service,
|
|
|
50
50
|
|
|
51
51
|
## MCP mapping and normal ingress admission
|
|
52
52
|
|
|
53
|
-
In Cabloy Basic, method/class `@
|
|
53
|
+
In Cabloy Basic, method/class `@Mcp.tool()` metadata opts registered Controller Actions into MCP mapping using their existing request/response contracts. It is independent of ordinary OpenAPI publication: `@Api.exclude()` is not an MCP authorization boundary, and publishing an HTTP action does not opt it into MCP. Decorator opt-in alone does **not** expose an action on normal `/mcp`.
|
|
54
54
|
|
|
55
55
|
Normal shared ingress is owned by `cabloy-mcp` and accepts recognized dev/test/prod modes through the same OAuth and business-authorization boundary. Production is not rejected solely by mode; unknown modes, not-ready, closing, disabled or invalid required authority configuration still fail closed. Dedicated protected audit, audit-delivery gating and deployment-wide fine-grained quotas are deferred for this two-read MVP, not delivered capabilities or production prerequisites. Enabled-by-default behavior is unchanged: only literal `CABLOY_MCP_ENABLED=false` disables it. Construction admission permits exactly `training-student.controller.student` actions `lookup` and `summary`, for both listing and known-name direct calls. Optional training absent means an empty catalog, without fallback. The three synthetic catalog tools and anonymous standalone entry belong only to the `mcp-test` launcher, not shared `/mcp`.
|
|
56
56
|
|
|
@@ -339,6 +339,8 @@ This is a clean example of attaching a behavior to a native element through a ge
|
|
|
339
339
|
|
|
340
340
|
### Perform a standalone asynchronous button action
|
|
341
341
|
|
|
342
|
+
Both Cabloy Basic and Cabloy Start provide `ZButton`. Prefer it for button actions, including menu actions, rather than rebuilding its action lifecycle with native elements. Keep navigation as links, and use native buttons or lower-level behaviors only when a specific interaction requires them. Resolve component imports and styling from the active edition; the implementation details below describe Cabloy Basic.
|
|
343
|
+
|
|
342
344
|
Cabloy Basic's `ZButton` attaches the `basic-button:perform` behavior to its native button. `BehaviorPerform` owns the button-local lifecycle for an action passed through `onPerform`:
|
|
343
345
|
|
|
344
346
|
- loading presentation and `aria-busy`
|
|
@@ -363,6 +365,16 @@ Pass the complete interaction promise through `onPerform`. For a button-only act
|
|
|
363
365
|
</ZButton>
|
|
364
366
|
```
|
|
365
367
|
|
|
368
|
+
For a model mutation whose failure should reach the button's error boundary, return or await `mutateAsync()`:
|
|
369
|
+
|
|
370
|
+
```tsx
|
|
371
|
+
<ZButton onPerform={() => this.$passport.logout().mutateAsync()}>
|
|
372
|
+
{this.scope.locale.Logout()}
|
|
373
|
+
</ZButton>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`mutate()` returns `void` and consumes the rejection, so even `await mutation.mutate()` cannot deliver that failure to the button's error boundary. `mutateAsync()` returns the complete mutation promise and rejects on failure. Keep success-only navigation and authentication cleanup in the owning mutation; do not move them into `finally` or clear authentication before the request succeeds.
|
|
377
|
+
|
|
366
378
|
Returning the complete promise is not enough when an API reports its failure as a result value. When a `ZButton` owns the generic error path for a query refetch, await a refetch that rejects on failure:
|
|
367
379
|
|
|
368
380
|
```tsx
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { APIRequestContext, Page, TestInfo } from '@playwright/test';
|
|
1
|
+
import type { APIRequestContext, Page, Route, TestInfo } from '@playwright/test';
|
|
2
2
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
@@ -636,6 +636,101 @@ test(
|
|
|
636
636
|
},
|
|
637
637
|
);
|
|
638
638
|
|
|
639
|
+
for (const flavor of ['web', 'admin'] as const) {
|
|
640
|
+
test(
|
|
641
|
+
`ATP-ACCOUNT-LOGOUT-${flavor.toUpperCase()}-01: rejected logout shows an error and preserves authentication`,
|
|
642
|
+
{ tag: ['@account', `@${flavor}`, '@flow'] },
|
|
643
|
+
async ({ page, request }, testInfo) => {
|
|
644
|
+
let account: RegisteredAccount | undefined;
|
|
645
|
+
const logoutRoute = (url: URL) => url.pathname === '/api/home/user/passport/logout';
|
|
646
|
+
const errorMessage = 'This operation is forbidden in demonstration mode';
|
|
647
|
+
let rejectedRequests = 0;
|
|
648
|
+
const rejectLogout = async (route: Route) => {
|
|
649
|
+
if (route.request().method() !== 'POST') return route.continue();
|
|
650
|
+
rejectedRequests++;
|
|
651
|
+
await route.fulfill({
|
|
652
|
+
status: 403,
|
|
653
|
+
contentType: 'application/json',
|
|
654
|
+
body: JSON.stringify({ code: 'demo-demonstration:1001', message: errorMessage }),
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
if (flavor === 'web') {
|
|
659
|
+
account = await registerAccountUser(request, testInfo, registered => {
|
|
660
|
+
account = registered;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const pageErrors = collectPageErrors(page);
|
|
664
|
+
const consoleErrors = collectConsoleErrors(page, [
|
|
665
|
+
/server responded with a status of 403/i,
|
|
666
|
+
]);
|
|
667
|
+
const loginPath = flavor === 'admin' ? '/admin/login' : '/login';
|
|
668
|
+
await page.goto(loginPath, { waitUntil: 'load' });
|
|
669
|
+
await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', flavor);
|
|
670
|
+
await page.getByPlaceholder('Your Username').fill(account?.username ?? 'admin');
|
|
671
|
+
await page.getByPlaceholder('Your Password').fill(account?.password ?? '123456');
|
|
672
|
+
await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue('');
|
|
673
|
+
const loginResponse = waitForApiResponse(page, 'POST', passportLoginApiPath);
|
|
674
|
+
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
|
675
|
+
expect((await loginResponse).ok()).toBeTruthy();
|
|
676
|
+
await expect(page).not.toHaveURL(new RegExp(`${loginPath}(?:\\?|$)`));
|
|
677
|
+
const signedInUrl = page.url();
|
|
678
|
+
const token = (await page.context().cookies()).find(
|
|
679
|
+
cookie => cookie.name === 'token',
|
|
680
|
+
)?.value;
|
|
681
|
+
expect(token).toBeTruthy();
|
|
682
|
+
const userMenu = page
|
|
683
|
+
.locator('details')
|
|
684
|
+
.filter({ has: page.getByText('Logout', { exact: true }) });
|
|
685
|
+
await expect(userMenu).toHaveCount(1);
|
|
686
|
+
const logout = userMenu.getByText('Logout', { exact: true });
|
|
687
|
+
await page.route(logoutRoute, rejectLogout);
|
|
688
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
689
|
+
await userMenu.locator('summary').click();
|
|
690
|
+
await logout.click();
|
|
691
|
+
const dialog = page.getByRole('dialog');
|
|
692
|
+
await expect(dialog).toHaveCount(1);
|
|
693
|
+
await expect(dialog).toBeVisible();
|
|
694
|
+
await expect(dialog).toContainText(errorMessage);
|
|
695
|
+
await expect(userMenu).not.toHaveAttribute('open', '');
|
|
696
|
+
await expect(logout).not.toHaveAttribute('aria-busy', 'true');
|
|
697
|
+
expect(rejectedRequests).toBe(attempt);
|
|
698
|
+
expect(page.url()).toBe(signedInUrl);
|
|
699
|
+
expect(
|
|
700
|
+
(await page.context().cookies()).find(cookie => cookie.name === 'token')?.value,
|
|
701
|
+
).toBe(token);
|
|
702
|
+
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
|
703
|
+
await expect(dialog).toHaveCount(0);
|
|
704
|
+
}
|
|
705
|
+
await userMenu.locator('summary').click();
|
|
706
|
+
const accountResponse = waitForApiResponse(page, 'GET', accountApiPath);
|
|
707
|
+
await userMenu.getByText('Account Settings', { exact: true }).click();
|
|
708
|
+
expect((await accountResponse).ok()).toBeTruthy();
|
|
709
|
+
await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible();
|
|
710
|
+
await page.unroute(logoutRoute, rejectLogout);
|
|
711
|
+
if (flavor === 'web') {
|
|
712
|
+
await userMenu.locator('summary').click();
|
|
713
|
+
const logoutResponse = waitForApiResponse(page, 'POST', '/api/home/user/passport/logout');
|
|
714
|
+
await logout.click();
|
|
715
|
+
expect((await logoutResponse).ok()).toBeTruthy();
|
|
716
|
+
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
|
717
|
+
await expect(page.getByText('Logout', { exact: true })).toHaveCount(0);
|
|
718
|
+
expect(
|
|
719
|
+
(await page.context().cookies()).find(cookie => cookie.name === 'token')?.value,
|
|
720
|
+
).toBeFalsy();
|
|
721
|
+
await page.goto(accountPath, { waitUntil: 'load' });
|
|
722
|
+
await expect(page).toHaveURL(/\/login(?:\?|$)/);
|
|
723
|
+
}
|
|
724
|
+
expect(pageErrors).toEqual([]);
|
|
725
|
+
expect(consoleErrors).toEqual([]);
|
|
726
|
+
} finally {
|
|
727
|
+
if (!page.isClosed()) await page.unroute(logoutRoute, rejectLogout);
|
|
728
|
+
if (account) await removeAccountFixture(request, account);
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
639
734
|
test(
|
|
640
735
|
'ATP-ACCOUNT-ADMIN-01: Account Settings precedes Logout and reaches the shared page',
|
|
641
736
|
{ tag: ['@account', '@admin'] },
|
|
@@ -663,8 +758,8 @@ test(
|
|
|
663
758
|
await expect(userMenu).toHaveCount(1);
|
|
664
759
|
const summary = userMenu.locator('summary');
|
|
665
760
|
await summary.click();
|
|
666
|
-
const
|
|
667
|
-
await expect(
|
|
761
|
+
const actions = userMenu.locator('ul > li > a, ul > li > button');
|
|
762
|
+
await expect(actions).toHaveText(['Account Settings', 'Logout']);
|
|
668
763
|
|
|
669
764
|
const accountResponse = page.waitForResponse(response => {
|
|
670
765
|
const url = new URL(response.url());
|
package/vona/env/.env
CHANGED
|
@@ -14,3 +14,30 @@ test('excludes timestamped generated Vona roots from bean module transforms', ()
|
|
|
14
14
|
);
|
|
15
15
|
assert.equal(isValidBeanModuleFile('/project/vona/src/module/foo/src/index.ts'), true);
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
for (const relativePath of [
|
|
19
|
+
'src/boot/app/index.ts',
|
|
20
|
+
'.zova/app/index.ts',
|
|
21
|
+
'src/backend/view.tsx',
|
|
22
|
+
'.vona/app.ts',
|
|
23
|
+
]) {
|
|
24
|
+
for (const separator of ['/', '\\']) {
|
|
25
|
+
test(`excludes ${relativePath} with ${JSON.stringify(separator)} separators`, () => {
|
|
26
|
+
const fileName = `/project/vona-bin-build-fresh-a1b2c3/${relativePath}`.replaceAll(
|
|
27
|
+
'/',
|
|
28
|
+
separator,
|
|
29
|
+
);
|
|
30
|
+
assert.equal(isValidBeanModuleFile(fileName), false);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const separator of ['/', '\\']) {
|
|
36
|
+
test(`includes module source with ${JSON.stringify(separator)} separators`, () => {
|
|
37
|
+
const fileName = '/project/vona/src/module/test-fixture/src/service/main.ts'.replaceAll(
|
|
38
|
+
'/',
|
|
39
|
+
separator,
|
|
40
|
+
);
|
|
41
|
+
assert.equal(isValidBeanModuleFile(fileName), true);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -95,6 +95,7 @@ function __createDecoratorNode(beanInfo) {
|
|
|
95
95
|
|
|
96
96
|
export function isValidBeanModuleFile(fileName?: string | null) {
|
|
97
97
|
if (!fileName) return false;
|
|
98
|
+
fileName = fileName.replace(/\\/g, '/');
|
|
98
99
|
if (/[\\/].vona-test-\d{14}-[a-f0-9]{8}[\\/]/.test(fileName)) return false;
|
|
99
100
|
return !['src/boot/app/', '.zova/app/', 'src/backend/', '.vona/'].some(item => {
|
|
100
101
|
return fileName.includes(item);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.123",
|
|
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": [
|
|
@@ -30,7 +30,6 @@ export interface IGuardOptionsDemonstration extends IDecoratorGuardOptionsGlobal
|
|
|
30
30
|
methodsForWrite,
|
|
31
31
|
usernameWhitelist: parseUsernameWhitelist(app.meta.env.DEMONSTRATION_USERNAME_WHITELIST),
|
|
32
32
|
ignore: [
|
|
33
|
-
// '/home/user/passport/logout',
|
|
34
33
|
'/home/user/passport/register',
|
|
35
34
|
'/home/user/passport/login',
|
|
36
35
|
'/home/user/passport/login/:module/:providerName/:clientName?',
|
|
@@ -59,6 +58,14 @@ export class GuardDemonstration extends BeanBase implements IGuardExecute {
|
|
|
59
58
|
return next();
|
|
60
59
|
}
|
|
61
60
|
|
|
61
|
+
if (
|
|
62
|
+
method === 'POST' &&
|
|
63
|
+
this.ctx.route?.routePathRaw === '/home/user/passport/logout' &&
|
|
64
|
+
this.bean.passport.authTokenMode === 'session'
|
|
65
|
+
) {
|
|
66
|
+
return next();
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
const user = this.bean.passport.currentUser;
|
|
63
70
|
if (
|
|
64
71
|
this.bean.passport.isAuthenticated &&
|
|
@@ -8,6 +8,7 @@ const guardBeanName = 'demo-demonstration.guard.demonstration';
|
|
|
8
8
|
const businessRoute = {
|
|
9
9
|
controllerBeanFullName: 'test.controller.business',
|
|
10
10
|
action: 'write',
|
|
11
|
+
routePathRaw: '/test/business/write',
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
function guard() {
|
|
@@ -101,7 +102,81 @@ describe('guardDemonstration.test.ts', { concurrency: false }, () => {
|
|
|
101
102
|
});
|
|
102
103
|
});
|
|
103
104
|
|
|
104
|
-
it('
|
|
105
|
+
it('allows non-whitelisted logout only in session auth-token mode', async t => {
|
|
106
|
+
await app.bean.executor.mockCtx(async () => {
|
|
107
|
+
const passport = app.bean.passport;
|
|
108
|
+
await passport.signinMock('admin');
|
|
109
|
+
try {
|
|
110
|
+
setRequest('POST', { ...businessRoute, routePathRaw: '/home/user/passport/logout' });
|
|
111
|
+
for (const authTokenMode of ['auth', 'session'] as const) {
|
|
112
|
+
const getter = t.mock.getter(passport, 'authTokenMode', () => authTokenMode);
|
|
113
|
+
try {
|
|
114
|
+
let continued = false;
|
|
115
|
+
const [result, err] = await catchError(() =>
|
|
116
|
+
guard().execute(
|
|
117
|
+
{ enable: true, methodsForWrite: ['POST'], usernameWhitelist: [] } as any,
|
|
118
|
+
async () => {
|
|
119
|
+
continued = true;
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
if (authTokenMode === 'session') {
|
|
125
|
+
assert.ifError(err);
|
|
126
|
+
assert.equal(result, true);
|
|
127
|
+
assert.equal(continued, true);
|
|
128
|
+
} else {
|
|
129
|
+
assert.equal(err?.code, 'demo-demonstration:1001');
|
|
130
|
+
assert.equal(err?.status, 403);
|
|
131
|
+
assert.equal(continued, false);
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
getter.mock.restore();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
await passport.signout();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('keeps other writes protected in session mode', async t => {
|
|
144
|
+
await app.bean.executor.mockCtx(async () => {
|
|
145
|
+
const passport = app.bean.passport;
|
|
146
|
+
await passport.signinMock('admin');
|
|
147
|
+
try {
|
|
148
|
+
const getter = t.mock.getter(passport, 'authTokenMode', () => 'session' as const);
|
|
149
|
+
try {
|
|
150
|
+
for (const [method, routePathRaw] of [
|
|
151
|
+
['POST', businessRoute.routePathRaw],
|
|
152
|
+
['DELETE', '/home/user/passport/logout'],
|
|
153
|
+
['POST', '/home/user/passport/logout/'],
|
|
154
|
+
]) {
|
|
155
|
+
setRequest(method, { ...businessRoute, routePathRaw });
|
|
156
|
+
let continued = false;
|
|
157
|
+
const [_, err] = await catchError(() =>
|
|
158
|
+
guard().execute(
|
|
159
|
+
{ enable: true, methodsForWrite: ['POST', 'DELETE'], usernameWhitelist: [] } as any,
|
|
160
|
+
async () => {
|
|
161
|
+
continued = true;
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
assert.equal(err?.code, 'demo-demonstration:1001');
|
|
167
|
+
assert.equal(err?.status, 403);
|
|
168
|
+
assert.equal(continued, false);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
getter.mock.restore();
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
await passport.signout();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('exempts the configured authentication and OAuth routes but still selects logout', () => {
|
|
105
180
|
for (const routePathRaw of [
|
|
106
181
|
'/home/user/passport/login',
|
|
107
182
|
'/home/user/passport/login/:module/:providerName/:clientName?',
|