cabloy 5.1.179 → 5.1.180

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.
Files changed (58) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/scheduled_tasks.lock +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/package.json +1 -1
  5. package/repo-docs/.vitepress/config.mjs +1 -0
  6. package/repo-docs/backend/controller-aop-guide.md +1 -1
  7. package/repo-docs/backend/demonstration-mode-guide.md +130 -0
  8. package/repo-docs/frontend/page-meta-guide.md +91 -13
  9. package/repo-docs/frontend/router-view-hosts-guide.md +37 -0
  10. package/repo-docs/fullstack/framework-performance.md +3 -3
  11. package/vona/packages-cli/cli/package.json +1 -1
  12. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +7 -7
  13. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +93 -39
  14. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +1 -1
  15. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +1 -1
  16. package/vona/packages-cli/cli-set-api/package.json +1 -1
  17. package/vona/packages-vona/vona/package.json +1 -1
  18. package/vona/pnpm-lock.yaml +1862 -2223
  19. package/vona/src/suite/a-demo/modules/demo-demonstration/package.json +55 -0
  20. package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/index.ts +68 -0
  21. package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/locales.ts +18 -0
  22. package/vona/src/suite/a-demo/modules/demo-demonstration/src/.metadata/this.ts +2 -0
  23. package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +68 -0
  24. package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/errors.ts +6 -0
  25. package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/locale/en-us.ts +3 -0
  26. package/vona/src/suite/a-demo/modules/demo-demonstration/src/config/locale/zh-cn.ts +3 -0
  27. package/vona/src/suite/a-demo/modules/demo-demonstration/src/index.ts +3 -0
  28. package/vona/src/suite/a-demo/modules/demo-demonstration/src/types/index.ts +8 -0
  29. package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +121 -0
  30. package/vona/src/suite/a-demo/modules/demo-demonstration/tsconfig.build.json +11 -0
  31. package/vona/src/suite/a-demo/modules/demo-demonstration/tsconfig.json +7 -0
  32. package/vona/src/suite/a-demo/package.json +2 -1
  33. package/vona/src/suite/a-demo/tsconfig.json +3 -0
  34. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +9 -0
  35. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +9 -0
  36. package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +19 -17
  37. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +47 -27
  38. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  39. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacScope.ts +13 -7
  40. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/test/rbacScopeCurrent.test.ts +28 -0
  41. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  42. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  43. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  44. package/zova/packages-utils/zova-jsx/package.json +2 -2
  45. package/zova/packages-zova/zova/package.json +3 -3
  46. package/zova/packages-zova/zova-core/package.json +1 -1
  47. package/zova/packages-zova/zova-core/src/bean/type.ts +1 -0
  48. package/zova/packages-zova/zova-core/src/composables/useController.ts +1 -1
  49. package/zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx +26 -7
  50. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  51. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/const.ts +1 -0
  52. package/zova/src/suite-vendor/a-zova/modules/a-router/src/monkey.ts +39 -2
  53. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  54. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/pageHost.ts +9 -0
  55. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/router.ts +2 -0
  56. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/pageHost.test.ts +53 -0
  57. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  58. package/zova/src/suite-vendor/a-zova/package.json +3 -3
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.179
1
+ 5.1.180
@@ -1 +1 @@
1
- {"sessionId":"6217847e-59ab-4e60-bb87-bbecdd19f760","pid":92862,"procStart":"Sat Sep 19 06:45:25 2026","acquiredAt":1789807599408}
1
+ {"sessionId":"f94cb5dc-de70-445c-8874-218b19d56077","pid":47778,"procStart":"Sun Sep 20 12:39:48 2026","acquiredAt":1789961541374}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.180
4
+
5
+ ### Features
6
+
7
+ - Add support for `$pageHost?.active`.
8
+ - Prepare CRUD resources for RBAC integration.
9
+ - Add demonstration capabilities.
10
+ - Update framework functionality.
11
+
12
+ ### Bug Fixes
13
+
14
+ - Fix the RBAC adapter.
15
+
16
+ ### Improvements
17
+
18
+ - Refactor the demo demonstration.
19
+ - Update framework performance documentation.
20
+ - Improve page metadata documentation.
21
+
3
22
  ## 5.1.179
4
23
 
5
24
  ### Improvements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.179",
3
+ "version": "5.1.180",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -308,6 +308,7 @@ export default defineConfig({
308
308
  { text: 'Auth Guide', link: '/backend/auth-guide' },
309
309
  { text: 'Captcha Guide', link: '/backend/captcha-guide' },
310
310
  { text: 'Rate Limit Guide', link: '/backend/rate-limit-guide' },
311
+ { text: 'Demonstration Mode Guide', link: '/backend/demonstration-mode-guide' },
311
312
  { text: 'User Access Guide', link: '/backend/user-access-guide' },
312
313
  { text: 'JWT Guide', link: '/backend/jwt-guide' },
313
314
  { text: 'Validation Guide', link: '/backend/validation-guide' },
@@ -81,7 +81,7 @@ The `@Core.gate(...)` shorthand still maps to `@Aspect.middlewareGlobal('a-core:
81
81
 
82
82
  ## Guard
83
83
 
84
- Guards are used for access control and execution preconditions.
84
+ Guards are used for access control and execution preconditions. For a concrete global Guard that controls write admission in a shared demo deployment, see [Demonstration Mode Guide](/backend/demonstration-mode-guide).
85
85
 
86
86
  Typical jobs include:
87
87
 
@@ -0,0 +1,130 @@
1
+ # Demonstration Mode Guide
2
+
3
+ <Badge type="tip" text="Common" />
4
+
5
+ Demonstration Mode is an application-wide write-admission policy for shared demonstration deployments. It is provided by the first-party `a-demo` suite and uses a global Vona Guard to prevent ordinary visitors from changing demo data.
6
+
7
+ It is disabled by default. When enabled, it protects selected HTTP write methods while leaving the rest of the application's authentication and authorization policies in force.
8
+
9
+ ## What Demonstration Mode does—and does not do
10
+
11
+ Demonstration Mode is a safety rail for demo data. It is **not** a replacement for:
12
+
13
+ - Passport authentication
14
+ - RBAC or domain-specific authorization
15
+ - validation and business rules
16
+ - WAF, reverse-proxy, or network controls
17
+ - database and other data-access safeguards
18
+
19
+ Approval by this Guard only lets the request continue to the normal request pipeline; it does not grant the caller any additional permission.
20
+
21
+ The policy applies to HTTP requests only. It does not protect side-effecting `GET` handlers, background jobs, scheduled work, queues, CLI execution, or other non-HTTP code. Model state-changing HTTP APIs with the protected write methods and retain their ordinary authorization rules.
22
+
23
+ ## Enable Demonstration Mode
24
+
25
+ Configure the backend environment and restart Vona:
26
+
27
+ ```dotenv
28
+ DEMONSTRATION_ENABLED=true
29
+ DEMONSTRATION_USERNAME_WHITELIST=admin,demo-editor
30
+ ```
31
+
32
+ `DEMONSTRATION_ENABLED` enables the Guard only when its value is the exact lowercase string `true`. An unset value, an empty value, `TRUE`, `True`, and `1` all leave Demonstration Mode disabled.
33
+
34
+ Use [Runtime and Flavors](/backend/runtime-and-flavors#env-file-resolution-and-precedence) to select the active environment files and understand precedence. In a local backend setup, an uncommitted `vona/env/.env.local` is a typical place for local overrides. Deployment-injected environment values may also take precedence. For the broader configuration model, see the [Config Guide](/backend/config-guide).
35
+
36
+ Environment values are evaluated while the backend initializes. Restart Vona after changing either Demonstration Mode variable.
37
+
38
+ ## Configure exempt writers
39
+
40
+ `DEMONSTRATION_USERNAME_WHITELIST` is a comma-separated list of usernames that may perform protected writes:
41
+
42
+ ```dotenv
43
+ DEMONSTRATION_USERNAME_WHITELIST=admin, demo-editor, content-manager
44
+ ```
45
+
46
+ The list is normalized as follows:
47
+
48
+ - surrounding whitespace is removed from each entry
49
+ - empty entries are discarded
50
+ - repeated entries are deduplicated
51
+ - usernames match entries exactly and case-sensitively
52
+
53
+ Whitelist membership is necessary but not sufficient. A protected write is admitted only when the current user is all of the following:
54
+
55
+ - authenticated
56
+ - account-active
57
+ - non-anonymous
58
+ - present in the username whitelist
59
+
60
+ If the whitelist is absent or empty while Demonstration Mode is enabled, no user qualifies for protected writes.
61
+
62
+ ## Request behavior
63
+
64
+ The Guard protects these HTTP methods:
65
+
66
+ - `POST`
67
+ - `PATCH`
68
+ - `DELETE`
69
+ - `PUT`
70
+
71
+ Other methods pass through this Guard unchanged. A protected request is allowed only for an eligible whitelisted user. All other protected requests are rejected with:
72
+
73
+ | Field | Value |
74
+ | ---------------------- | ------------------------------------------------- |
75
+ | HTTP status | `403 Forbidden` |
76
+ | Application error code | `demo-demonstration:1001` |
77
+ | Meaning | The operation is forbidden in demonstration mode. |
78
+
79
+ The Guard depends on Passport-resolved account state, so it makes this decision only after the current account information is available. Passing the Demonstration Mode check does not bypass later Passport, RBAC, or business-policy checks.
80
+
81
+ ## Exempt authentication routes
82
+
83
+ Demonstration Mode deliberately does not apply to the standard authentication and OAuth routes below. This keeps sign-in, sign-out, registration, account association and migration, OAuth callbacks, and token flows available while the write policy is active.
84
+
85
+ ```text
86
+ /home/user/passport/logout
87
+ /home/user/passport/register
88
+ /home/user/passport/login
89
+ /home/user/passport/login/:module/:providerName/:clientName?
90
+ /home/user/passport/associate/:module/:providerName/:clientName?
91
+ /home/user/passport/migrate/:module/:providerName/:clientName?
92
+ /home/user/passport/refreshAuthToken
93
+ /home/user/passport/createPassportJwtFromOauthCode
94
+ /home/user/passport/createTempAuthToken
95
+ /auth/passport/callback
96
+ ```
97
+
98
+ These are route templates used by the built-in authentication flows. They are not a general mechanism for bypassing Demonstration Mode on arbitrary URLs.
99
+
100
+ ## Deploy with the `a-demo` suite
101
+
102
+ The `demo-demonstration` module is included in the current project's `a-demo` suite; no separate package installation is required.
103
+
104
+ If an operator intentionally excludes the whole suite:
105
+
106
+ ```dotenv
107
+ PROJECT_DISABLED_SUITES=a-demo
108
+ ```
109
+
110
+ then `demo-demonstration` and its Guard are not loaded, regardless of `DEMONSTRATION_ENABLED` or the whitelist. This is expected when a deployment does not include demo capabilities.
111
+
112
+ For ordinary policy changes, use `DEMONSTRATION_ENABLED` rather than disabling the suite. Reserve `PROJECT_DISABLED_SUITES=a-demo` for deployments that intentionally exclude all `a-demo` functionality.
113
+
114
+ ## Troubleshooting
115
+
116
+ | Symptom | Check |
117
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118
+ | The policy appears inactive | Confirm `DEMONSTRATION_ENABLED=true` uses exact lowercase spelling, then restart Vona and check active environment-file and deployment-variable precedence. |
119
+ | An intended demo editor receives `403` | Confirm that the user is authenticated, active, non-anonymous, and that their username exactly matches a whitelist entry, including case. |
120
+ | A request is unexpectedly allowed | Confirm whether it uses a method other than `POST`, `PATCH`, `DELETE`, or `PUT`, or whether it belongs to an exempt built-in authentication route. |
121
+ | The policy never loads | Confirm that `a-demo` is not listed in `PROJECT_DISABLED_SUITES`. |
122
+ | A non-HTTP mutation still runs | Demonstration Mode only guards applicable HTTP requests; add the appropriate authorization and execution controls to the relevant job, queue, CLI, or service path. |
123
+
124
+ ## Related guides
125
+
126
+ - [Runtime and Flavors](/backend/runtime-and-flavors#env-file-resolution-and-precedence)
127
+ - [Config Guide](/backend/config-guide)
128
+ - [Controller AOP Guide](/backend/controller-aop-guide#guard)
129
+ - [Auth Guide](/backend/auth-guide)
130
+ - [User Access Guide](/backend/user-access-guide)
@@ -38,8 +38,8 @@ The shortest accurate model is:
38
38
  2. page code calls `$router.setPageMeta(this.$pageRoute, pageMeta)` when task-level presentation should change
39
39
  3. the shared router bean forwards that update to registered router-view hosts
40
40
  4. `routerViewTabs` delegates the update to `ModelTabs`
41
- 5. `ModelTabs` stores the metadata on the current level-2 tab item
42
- 6. the active layout reads that metadata to render task-level title and icon state
41
+ 5. `ModelTabs` stores the metadata on the level-2 work item matched by the route's `fullPath`
42
+ 6. the relevant layout reads that metadata to render task-level title and icon state
43
43
 
44
44
  That is why page metadata is not the same thing as route metadata.
45
45
 
@@ -57,13 +57,14 @@ The highest-value current Basic source path is:
57
57
  5. `zova/src/suite-vendor/a-zova/modules/a-routertabs/src/component/routerViewTabs/controller.tsx` overrides `setPageMeta(...)` and delegates to `ModelTabs`
58
58
  6. `zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts` stores and merges `pageMeta` on the routed work item
59
59
  7. `zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx` shows the clearest current authoring path
60
- 8. `zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx` shows the clearest current visible shell consumer
60
+ 8. `zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx` shows a current custom-render page-meta call site
61
+ 9. `zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx` shows the clearest current visible shell consumer
61
62
 
62
63
  A compact interpretation is:
63
64
 
64
65
  - page code emits page meta through `$router.setPageMeta(...)`
65
- - the router forwards it to the active routed host
66
- - the tabs host stores it on the routed work item
66
+ - the router forwards it to all registered routed hosts
67
+ - each host may handle the update or ignore it; the tabs host stores it on the work item matched by the route
67
68
  - the shell renders title, dirty, and form-scene signals from that stored metadata
68
69
 
69
70
  ## The public page-meta surface
@@ -81,9 +82,39 @@ export interface IPageMeta {
81
82
  pageTitle?: string;
82
83
  pageDirty?: boolean;
83
84
  formMeta?: IFormMeta;
85
+ onCustomRender?: (tabItem: IRouteViewRouteItem) => VNodeChild;
86
+ onCustomRenderIsolate?: (tabCurrent: IRouteViewTabCurrent) => VNodeChild;
84
87
  }
85
88
  ```
86
89
 
90
+ The two render callbacks are optional shell-rendering hooks. They are useful when a page needs to replace the usual text-only task label with a small reactive presentation, such as a status badge or clock. They are not a replacement for the page's main `render()` output.
91
+
92
+ ### `onCustomRender`
93
+
94
+ `onCustomRender` customizes the content of a non-anchor level-2 tab item. The callback receives the corresponding `IRouteViewRouteItem` and returns a Vue `VNodeChild`:
95
+
96
+ ```typescript
97
+ this.$router.setPageMeta(this.$pageRoute, {
98
+ onCustomRender: tabItem => <span>{tabItem.pageMeta?.pageTitle || 'Working'}</span>,
99
+ });
100
+ ```
101
+
102
+ In the current Admin tabs renderer, this callback takes precedence over `pageTitle` for that tab item. The returned content is rendered inside the level-2 tab label. Use the callback argument for stable tab-item metadata; use the page/controller's reactive state when the label itself needs to update.
103
+
104
+ ### `onCustomRenderIsolate`
105
+
106
+ `onCustomRenderIsolate` customizes the active anchor item's isolated level-2 presentation. The callback receives the current `IRouteViewTabCurrent` and returns a Vue `VNodeChild`:
107
+
108
+ ```typescript
109
+ this.$router.setPageMeta(this.$pageRoute, {
110
+ onCustomRenderIsolate: () => <span class="badge">Online</span>,
111
+ });
112
+ ```
113
+
114
+ In the current Admin tabs renderer, this callback has precedence over the anchor's `pageTitle` and the ordinary level-2 tab list. It is therefore appropriate for a page-specific shell area that should be shown in place of the default current-item title. The current Basic dashboard uses this hook for its live time badge.
115
+
116
+ These callbacks are currently consumed by the Admin layout's tabs renderer. Do not assume that every routed host or every edition renders them; verify the concrete host and layout before relying on custom shell output.
117
+
87
118
  ### `pageTitle`
88
119
 
89
120
  `pageTitle` is the task-level title for the current routed work item.
@@ -224,7 +255,7 @@ setPageMeta(route, pageMeta) {
224
255
 
225
256
  This means the router bean is not itself the state owner.
226
257
 
227
- It is the forwarding boundary between page code and routed hosts.
258
+ It is the forwarding boundary between page code and routed hosts. The base router-view host provides a no-op `setPageMeta(...)`, so a host that does not override it can ignore the update without changing the page or route record.
228
259
 
229
260
  ### 3. `routerViewTabs` delegates to `ModelTabs`
230
261
 
@@ -241,7 +272,7 @@ public setPageMeta(route, pageMeta) {
241
272
  }
242
273
  ```
243
274
 
244
- Inside `ModelTabs`, the update is resolved by `route.fullPath`, then applied to the current tab item.
275
+ Inside `ModelTabs`, the update is resolved by searching the tab items for the matching `route.fullPath`, then applied to that routed work item. The incoming object is shallow-merged with the existing `pageMeta`, so a later call can update one field without clearing the other fields already stored on that work item.
245
276
 
246
277
  This is the crucial source-level fact:
247
278
 
@@ -249,16 +280,29 @@ This is the crucial source-level fact:
249
280
 
250
281
  ### 4. The active layout consumes the stored page meta
251
282
 
252
- Representative Basic consumers:
283
+ The clearest Basic page-meta consumer is the Admin layout:
253
284
 
254
285
  - `zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx`
286
+
287
+ The Web layout has a related tabs presentation, but its current renderer does not read `pageMeta`:
288
+
255
289
  - `zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.tabs.tsx`
256
290
 
257
- In the current Basic source, the layout can use page meta for:
291
+ In the current Admin layout, the renderer can use page meta for:
258
292
 
259
293
  - task-level title rendering
260
294
  - dirty indicators
261
295
  - create/edit icon signals derived from `formMeta.formScene`
296
+ - custom content for level-2 tab items through `onCustomRender`
297
+ - isolated content for the active anchor item through `onCustomRenderIsolate`
298
+
299
+ The current Admin tabs renderer applies custom content in this order:
300
+
301
+ 1. if the active anchor item has `onCustomRenderIsolate`, render that isolated result
302
+ 2. otherwise, if the anchor item has `pageTitle`, render that title
303
+ 3. otherwise, render the other level-2 items; each item uses `onCustomRender(tabItem)` when present, otherwise its `pageTitle`
304
+
305
+ When tabs caching is enabled, this renderer wraps the custom shell output in `ClientOnly`. The current Web layout's tabs renderer does not read these page-meta callbacks, so do not assume that the same custom output appears in every layout or routed host.
262
306
 
263
307
  ## The most important authoring pattern
264
308
 
@@ -355,13 +399,47 @@ this.$router.setPageMeta(this.$pageRoute, {
355
399
 
356
400
  In the current Basic source, this lets the layout derive task-level icon treatment from `formMeta.formScene`.
357
401
 
358
- ### Scenario 4: I only want to change browser document title
402
+ ### Scenario 4: I need a custom task-level shell presentation
403
+
404
+ Use one of the custom render hooks when the normal title text is not enough:
405
+
406
+ ```typescript
407
+ this.$router.setPageMeta(this.$pageRoute, {
408
+ onCustomRenderIsolate: () => (
409
+ <span class="badge badge-primary">{this.statusText}</span>
410
+ ),
411
+ });
412
+ ```
413
+
414
+ Use `onCustomRender` for a non-anchor level-2 item and `onCustomRenderIsolate` when the active anchor item's presentation should replace the ordinary level-2 item row. The callback may close over reactive controller state, as in the current dashboard clock example:
415
+
416
+ - `zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx`
417
+
418
+ These are routed-shell presentation hooks, not browser document-title or SEO metadata APIs.
359
419
 
360
- Do not assume page meta is the right tool.
420
+ ### Scenario 5: I only want to change browser document title
421
+
422
+ Use [`$useMeta(...)` in the SSR SEO Meta guide](/frontend/ssr-seo-meta#usemeta), not `$router.setPageMeta(...)`.
423
+
424
+ `pageTitle` is a routed-shell task-title surface first. `$useMeta(...)` owns SSR-aware document-head metadata, including the browser title:
425
+
426
+ ```typescript
427
+ this.$useMeta({
428
+ title: 'Product catalogue',
429
+ });
430
+ ```
431
+
432
+ When the title depends on reactive page state, pass a function so the client metadata surface updates with that state:
433
+
434
+ ```typescript
435
+ this.$useMeta(() => ({
436
+ title: this.product?.name ?? 'Product',
437
+ }));
438
+ ```
361
439
 
362
- `pageTitle` is a routed-shell task-title surface first.
440
+ Use page meta as well only when the routed shell needs its task label to change independently. For example, a record editor may set `pageTitle` for its Admin tab and `$useMeta(...)` for the browser title; neither API automatically updates the other.
363
441
 
364
- If the requirement is only browser document-title behavior, verify the current document-title consumer path before reusing page meta for that purpose.
442
+ For title templates, descriptions, other head tags, and the static-versus-reactive behavior of `$useMeta(...)`, continue with [SSR SEO Meta](/frontend/ssr-seo-meta).
365
443
 
366
444
  ## A compact helper pattern
367
445
 
@@ -129,6 +129,43 @@ A compact interpretation is:
129
129
 
130
130
  This is the point where route state becomes host state.
131
131
 
132
+ ### Page activation state
133
+
134
+ A routed page and its descendant Zova components can read the optional `$pageHost` shortcut:
135
+
136
+ ```ts
137
+ this.$pageHost?.active;
138
+ ```
139
+
140
+ `$pageHost.active` is a stable, reactive page-host context value. It describes whether this concrete page component instance is currently activated by its Vue host. It is initialized as `true` for SSR and the initial client render, becomes `false` when a cached page is deactivated, and becomes `true` again when that same page instance is reactivated. It becomes `false` before final unmount as a cleanup safeguard.
141
+
142
+ Use it to pause work that does not need to continue while a cached page is inactive, for example timers, polling, subscriptions, media, or observers:
143
+
144
+ ```ts
145
+ this.$watch(
146
+ () => this.$pageHost?.active,
147
+ active => {
148
+ if (active) this.startPolling();
149
+ else this.stopPolling();
150
+ },
151
+ { immediate: true },
152
+ );
153
+ ```
154
+
155
+ Keep the browser-only work behind the existing SSR and hydration boundary. An initial `active === true` value does not mean that `window`, `document`, or other browser APIs exist during server rendering.
156
+
157
+ `$pageHost.active` is not:
158
+
159
+ - the current route or navigation intent
160
+ - membership in `keepAliveInclude`
161
+ - the selected Tabs or Stack model item
162
+ - browser visibility or window focus
163
+ - data-loading readiness
164
+
165
+ Therefore `active` and `keepAlive` answer different questions: `keepAlive` controls retention eligibility, while `$pageHost.active` reports the lifecycle state of the retained page instance. `__dispose__()` remains the final cleanup hook and is not called merely because a cached page is deactivated.
166
+
167
+ This context is available through the page bean-host hierarchy, so Page Controllers, Render/Style companions, and descendant components can observe the same page-host object. Nested routed pages receive their own nearest page host.
168
+
132
169
  ## Host 1: `routerViewEmpty`
133
170
 
134
171
  The minimal host controller lives in:
@@ -42,17 +42,17 @@ For the current public explanation of this backend capability, see [Cache Guide]
42
42
 
43
43
  This is the kind of result Cabloy is designed to support: a framework that stays operationally calm even when the system keeps running for long periods.
44
44
 
45
- One internally generated project was kept running continuously for **2 months**. At one representative PM2 snapshot, the process looked like this:
45
+ One internally generated project was kept running continuously for **3 months**. At one representative PM2 snapshot, the process looked like this:
46
46
 
47
47
  ```text
48
48
  ┌────┬─────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
49
49
  │ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
50
50
  ├────┼─────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
51
- │ 0 │ cabloy_*** │ default │ N/A │ cluster │ 226947 │ 2M │ 17 │ online │ 0% │ 388.4mb │ ubuntu │ disabled │
51
+ │ 0 │ cabloy_*** │ default │ N/A │ cluster │ 226947 │ 3M │ 17 │ online │ 0% │ 381.4mb │ ubuntu │ disabled │
52
52
  └────┴─────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
53
53
  ```
54
54
 
55
- For this 2-month continuous run, the observed result was **0 memory leak**.
55
+ For this 3-month continuous run, the observed result was **0 memory leak**.
56
56
 
57
57
  The value of this example is not that it is a synthetic micro-benchmark. The value is that it reflects a real long-running project process with a clear, inspectable runtime footprint.
58
58
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.151",
3
+ "version": "1.1.152",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -27,42 +27,42 @@ export interface IControllerOptions<%=argv.resourceNameCapitalize%> extends IDec
27
27
  export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
28
28
  @Web.post('', { summary: $locale('<%=argv.resourceNameCapitalize%>Create') })
29
29
  @Api.body(v.tableIdentity())
30
- @Passport.systemAdmin()
30
+ @Passport.rbac()
31
31
  async create(@Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Create): Promise<TableIdentity> {
32
32
  return (await this.scope.service.<%=argv.resourceName%>.create(<%=argv.resourceName%>)).id;
33
33
  }
34
34
 
35
35
  @Web.get('', { summary: $locale('<%=argv.resourceNameCapitalize%>Select') })
36
36
  @Api.body(Dto<%=argv.resourceNameCapitalize%>SelectRes)
37
- @Passport.systemAdmin()
37
+ @Passport.rbac()
38
38
  async select(@Arg.filter(Dto<%=argv.resourceNameCapitalize%>SelectReq) params: IQueryParams<Model<%=argv.resourceNameCapitalize%>>): Promise<Dto<%=argv.resourceNameCapitalize%>SelectRes> {
39
39
  return await this.scope.service.<%=argv.resourceName%>.select(params);
40
40
  }
41
41
 
42
42
  @Web.get(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>View') })
43
43
  @Api.body(v.optional(), v.object(Dto<%=argv.resourceNameCapitalize%>View))
44
- @Passport.systemAdmin()
44
+ @Passport.rbac()
45
45
  async view(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<Dto<%=argv.resourceNameCapitalize%>View | undefined> {
46
46
  return await this.scope.service.<%=argv.resourceName%>.view(id);
47
47
  }
48
48
 
49
49
  @Web.patch(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Update') })
50
50
  @Api.body(z.null())
51
- @Passport.systemAdmin()
51
+ @Passport.rbac()
52
52
  async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update): Promise<void> {
53
53
  await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
54
54
  }
55
55
 
56
56
  @Web.delete(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Delete') })
57
57
  @Api.body(z.null())
58
- @Passport.systemAdmin()
58
+ @Passport.rbac()
59
59
  async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
60
60
  await this.scope.service.<%=argv.resourceName%>.delete(id);
61
61
  }
62
62
 
63
- @Web.post('bulk/delete', { summary: $locale('BulkDelete') })
63
+ @Web.post('bulk/delete', { summary: $locale('<%=argv.resourceNameCapitalize%>DeleteBulk') })
64
64
  @Api.body(z.null())
65
- @Passport.systemAdmin()
65
+ @Passport.rbac({ actionInherit: 'delete' })
66
66
  async deleteBulk(@Arg.body() command: Dto<%=argv.resourceNameCapitalize%>DeleteBulk): Promise<void> {
67
67
  await this.scope.service.<%=argv.resourceName%>.deleteBulk(command.ids);
68
68
  }
@@ -1,12 +1,74 @@
1
1
  import type { Dto<%=argv.resourceNameCapitalize%>Create, Dto<%=argv.resourceNameCapitalize%>SelectRes, Dto<%=argv.resourceNameCapitalize%>Update, Entity<%=argv.resourceNameCapitalize%> } from 'vona-module-<%=argv.moduleInfo.relativeName%>';
2
+
2
3
  import assert from 'node:assert';
3
4
  import { describe, it } from 'node:test';
5
+ import { appMetadata } from 'vona';
4
6
  import { app } from 'vona-mock';
7
+ import { SymbolOpenApiOptions } from 'vona-module-a-openapiutils';
8
+
9
+ describe('<%=argv.resourceName%>.test.ts', { concurrency: false }, () => {
10
+ it('action:<%=argv.resourceName%>:openapiMetadata', async () => {
11
+ await app.bean.executor.mockCtx(async () => {
12
+ const controllerBeanFullName = '<%=argv.moduleInfo.relativeName%>.controller.<%=argv.resourceName%>';
13
+ const controller = app.bean.onion.controller
14
+ .getOnionsEnabledCached()
15
+ .find(item => item.beanOptions.beanFullName === controllerBeanFullName)
16
+ ?.beanOptions.beanClass;
17
+ if (!controller) throw new Error(`${controllerBeanFullName} not found`);
18
+
19
+ const controllerMetadata = appMetadata.getMetadata<any>(
20
+ SymbolOpenApiOptions,
21
+ controller,
22
+ );
23
+ assert.equal(
24
+ controllerMetadata?.summary?.toString(),
25
+ '<%=argv.moduleInfo.relativeName%>::<%=argv.resourceNameCapitalize%>Controller',
26
+ );
27
+
28
+ const expectedSummaries = {
29
+ create: ['post', '<%=argv.moduleActionPathRaw%>', 'Create <%=argv.resourceNameCapitalize%>'],
30
+ select: ['get', '<%=argv.moduleActionPathRaw%>', 'Query <%=argv.resourceNameCapitalize%> List'],
31
+ view: ['get', '<%=argv.moduleActionPathRaw%>/{id}', 'View <%=argv.resourceNameCapitalize%>'],
32
+ update: ['patch', '<%=argv.moduleActionPathRaw%>/{id}', 'Update <%=argv.resourceNameCapitalize%>'],
33
+ delete: ['delete', '<%=argv.moduleActionPathRaw%>/{id}', 'Delete <%=argv.resourceNameCapitalize%>'],
34
+ deleteBulk: ['post', '<%=argv.moduleActionPathRaw%>/bulk/delete', 'Bulk Delete <%=argv.resourceNameCapitalize%>'],
35
+ } as const;
36
+ for (const [action, [method, path, summary]] of Object.entries(expectedSummaries)) {
37
+ const apiJson = await app.bean.openapi.generateJsonOfControllerAction(
38
+ controller,
39
+ action,
40
+ 'V31',
41
+ );
42
+ const operation = (apiJson.paths as any)?.[path]?.[method];
43
+ assert.ok(operation, `${action} operation is missing`);
44
+ assert.equal(operation.summary?.toJSON(), summary);
45
+ }
46
+ });
47
+ });
48
+
49
+ it('action:<%=argv.resourceName%>:rbacCatalog', async () => {
50
+ await app.bean.executor.mockCtx(async () => {
51
+ const controllerBeanFullName = '<%=argv.moduleInfo.relativeName%>.controller.<%=argv.resourceName%>';
52
+ const catalog = app.bean.rbacCatalog.getCatalog();
53
+ const expectedInherits = {
54
+ create: undefined,
55
+ select: undefined,
56
+ view: undefined,
57
+ update: undefined,
58
+ delete: undefined,
59
+ deleteBulk: `${controllerBeanFullName}#delete`,
60
+ } as const;
61
+ for (const [action, actionInheritKey] of Object.entries(expectedInherits)) {
62
+ const descriptor = catalog.get(`${controllerBeanFullName}#${action}`);
63
+ assert.ok(descriptor, `${action} RBAC action is missing`);
64
+ assert.equal(descriptor.actionInheritKey, actionInheritKey);
65
+ assert.equal(descriptor.options.dataScope, undefined);
66
+ }
67
+ });
68
+ });
5
69
 
6
- describe('<%=argv.resourceName%>.test.ts', () => {
7
70
  it('action:<%=argv.resourceName%>', async () => {
8
71
  await app.bean.executor.mockCtx(async () => {
9
- // data
10
72
  const data: Dto<%=argv.resourceNameCapitalize%>Create = {
11
73
  name: '__Tom__',
12
74
  description: 'This is a test',
@@ -15,48 +77,40 @@ describe('<%=argv.resourceName%>.test.ts', () => {
15
77
  name: '__TomNew__',
16
78
  description: 'This is a test',
17
79
  };
18
- // role-less authenticated users cannot access generated admin actions
80
+ let <%=argv.resourceName%>Id: Entity<%=argv.resourceNameCapitalize%>['id'] | undefined;
81
+ // sign in as an unrestricted test identity
19
82
  await app.bean.passport.signinMock();
20
83
  try {
21
- app.bean.passport.current!.roles = [];
22
- const actions = ['create', 'select', 'view', 'update', 'delete'];
23
- const permissions = await Promise.all(
24
- actions.map(action =>
25
- app.bean.permission.retrievePermissionAction(
26
- '<%=argv.moduleInfo.relativeName%>:<%=argv.resourceName%>',
27
- action,
28
- ),
29
- ),
30
- );
31
- assert.deepEqual(permissions, actions.map(() => false));
84
+ // create
85
+ <%=argv.resourceName%>Id = await app.bean.executor.performAction('post', '<%=argv.moduleActionPathRaw%>', { body: data });
86
+ assert.equal(!!<%=argv.resourceName%>Id, true);
87
+ // findMany
88
+ const selectRes: Dto<%=argv.resourceNameCapitalize%>SelectRes = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>');
89
+ assert.equal(selectRes.list.findIndex(item => item.name === data.name) > -1, true);
90
+ // update
91
+ const updateRes = await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
92
+ params: { id: <%=argv.resourceName%>Id },
93
+ body: dataUpdate,
94
+ });
95
+ assert.equal(updateRes, null);
96
+ // findOne
97
+ let <%=argv.resourceName%>: Entity<%=argv.resourceNameCapitalize%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>Id } });
98
+ assert.equal(<%=argv.resourceName%>.name, dataUpdate.name);
99
+ // delete
100
+ const deleteRes = await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
101
+ assert.equal(deleteRes, null);
102
+ // findOne
103
+ <%=argv.resourceName%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
104
+ assert.equal(<%=argv.resourceName%>, undefined);
32
105
  } finally {
106
+ if (<%=argv.resourceName%>Id) {
107
+ await app.scope('<%=argv.moduleInfo.relativeName%>').model.<%=argv.resourceName%>.deleteById(
108
+ <%=argv.resourceName%>Id,
109
+ { disableDeleted: true },
110
+ );
111
+ }
33
112
  await app.bean.passport.signout();
34
113
  }
35
- // login as system admin
36
- await app.bean.passport.signinMock();
37
- // create
38
- const <%=argv.resourceName%>Id = await app.bean.executor.performAction('post', '<%=argv.moduleActionPathRaw%>', { body: data });
39
- assert.equal(!!<%=argv.resourceName%>Id, true);
40
- // findMany
41
- const selectRes: Dto<%=argv.resourceNameCapitalize%>SelectRes = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>');
42
- assert.equal(selectRes.list.findIndex(item => item.name === data.name) > -1, true);
43
- // update
44
- const updateRes = await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
45
- params: { id: <%=argv.resourceName%>Id },
46
- body: dataUpdate,
47
- });
48
- assert.equal(updateRes, null);
49
- // findOne
50
- let <%=argv.resourceName%>: Entity<%=argv.resourceNameCapitalize%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>Id } });
51
- assert.equal(<%=argv.resourceName%>.name, dataUpdate.name);
52
- // delete
53
- const deleteRes = await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
54
- assert.equal(deleteRes, null);
55
- // findOne
56
- <%=argv.resourceName%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
57
- assert.equal(<%=argv.resourceName%>, undefined);
58
- // logout
59
- await app.bean.passport.signout();
60
114
  });
61
115
  });
62
116
  });