cabloy 5.1.106 → 5.1.108

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 (60) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/cabloy-docs/.vitepress/config.mjs +6 -0
  4. package/cabloy-docs/backend/backend-resource-module-contract-chain.md +1 -0
  5. package/cabloy-docs/backend/controller-aop-guide.md +12 -0
  6. package/cabloy-docs/backend/controller-guide.md +45 -3
  7. package/cabloy-docs/backend/crud-workflow.md +14 -0
  8. package/cabloy-docs/backend/multi-instance-and-instance-resolution.md +18 -0
  9. package/cabloy-docs/backend/runtime-and-flavors.md +2 -2
  10. package/cabloy-docs/backend/scripts.md +3 -2
  11. package/cabloy-docs/backend/unit-testing.md +8 -2
  12. package/cabloy-docs/frontend/ssr-env.md +20 -0
  13. package/cabloy-docs/fullstack/deploy-cloudflare-docker.md +136 -0
  14. package/cabloy-docs/fullstack/openapi-to-sdk.md +1 -0
  15. package/cabloy-docs/fullstack/tutorial-2-first-crud.md +3 -0
  16. package/package.json +1 -1
  17. package/vona/docker-compose-original/config/nginx/conf.d/nginx.conf +1 -1
  18. package/vona/env/.env +1 -1
  19. package/vona/packages-cli/cli/package.json +1 -1
  20. package/vona/packages-cli/cli-set-api/cli/templates/tools/crud/boilerplate/src/bean/ssrMenu.{{resourceName}}.ts_ +1 -0
  21. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +13 -4
  22. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +22 -3
  23. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +13 -4
  24. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/test/{{resourceName}}.test.ts_ +22 -3
  25. package/vona/packages-cli/cli-set-api/package.json +1 -1
  26. package/vona/packages-vona/vona/package.json +1 -1
  27. package/vona/src/backend/config/config/config.ts +1 -1
  28. package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +2 -2
  29. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +10 -8
  30. package/vona/src/suite/a-home/modules/home-user/src/config/config.ts +6 -0
  31. package/vona/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +2 -0
  32. package/vona/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +2 -0
  33. package/vona/src/suite/a-home/modules/home-user/src/config/roles.ts +24 -0
  34. package/vona/src/suite/a-home/modules/home-user/src/entity/role.ts +7 -0
  35. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +45 -0
  36. package/vona/src/suite/a-training/modules/training-record/src/controller/record.ts +13 -4
  37. package/vona/src/suite/a-training/modules/training-record/test/record.test.ts +25 -2
  38. package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +18 -6
  39. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +33 -5
  40. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/cli/ssrMenuAdmin/boilerplate/{{sceneName}}.{{beanName}}.ts_ +1 -0
  41. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  42. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/service/ssrHandler.ts +1 -1
  43. package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
  44. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  45. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +3 -0
  46. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  47. package/zova/packages-cli/cli/package.json +2 -2
  48. package/zova/packages-cli/cli-set-front/package.json +1 -1
  49. package/zova/packages-cli/cli-set-front/src/lib/bean/cli.openapi.generate.ts +4 -0
  50. package/zova/packages-zova/zova/package.json +2 -2
  51. package/zova/src/suite/a-home/modules/home-api/mock/passport.fake.ts +50 -1
  52. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +15 -7
  53. package/zova/src/suite/a-home/modules/home-base/src/.metadata/index.ts +10 -0
  54. package/zova/src/suite/a-home/modules/home-base/src/page/errorAccessDenied/controller.tsx +1 -1
  55. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +26 -17
  56. package/zova/src/suite/cabloy-basic/modules/basic-file/src/api/openapi/types.ts +26 -17
  57. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +26 -17
  58. package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/package.json +1 -1
  59. package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/src/service/ssrHandler.ts +1 -1
  60. package/zova/src/suite-vendor/a-zova/package.json +2 -2
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.106
1
+ 5.1.108
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.108
4
+
5
+ ### Features
6
+
7
+ - Improve Docker domain deployment support.
8
+
9
+ ### Improvements
10
+
11
+ - Clarify shared deployment guidance.
12
+
13
+ ## 5.1.107
14
+
15
+ ### Features
16
+
17
+ - Localize persisted home roles.
18
+ - Define response contracts for resource mutations, including null responses.
19
+ - Secure generated CRUD actions.
20
+
21
+ ### Improvements
22
+
23
+ - Clarify built-in role configuration.
24
+
3
25
  ## 5.1.106
4
26
 
5
27
  ### Improvements
@@ -41,6 +41,12 @@ const fullstackGroups = [
41
41
  { text: 'Suites and Modules', link: '/fullstack/suites-and-modules' },
42
42
  ],
43
43
  },
44
+ {
45
+ text: 'Fullstack / Deployment',
46
+ items: [
47
+ { text: 'Docker + Cloudflare Deployment', link: '/fullstack/deploy-cloudflare-docker' },
48
+ ],
49
+ },
44
50
  {
45
51
  text: 'Fullstack / Tutorials',
46
52
  items: [
@@ -143,6 +143,7 @@ Representative source facts from this file:
143
143
  - the class is decorated with `@Controller<IControllerOptionsStudent>('student')`
144
144
  - the class is also decorated with `@Resource()`
145
145
  - CRUD actions are declared with `@Web.post()`, `@Web.get()`, `@Web.get(':id')`, `@Web.patch(':id')`, and `@Web.delete(':id')`
146
+ - the standard CRUD actions and the `summary` / `deleteForce` management actions explicitly use `@Passport.systemAdmin()`
146
147
  - request shapes use `@Arg.body(...)`, `@Arg.param(...)`, and `@Arg.filter(...)`
147
148
  - actions delegate to `this.scope.service.student`
148
149
 
@@ -99,6 +99,18 @@ Typical jobs include:
99
99
 
100
100
  These shorthands still map back to the generic aspect model.
101
101
 
102
+ ### Passport inheritance and action policy
103
+
104
+ The global Passport guard is the baseline for controller actions: without a local Passport decorator, an action requires an authenticated and activated user. It is not public.
105
+
106
+ Use a local Passport or domain guard when an action needs a policy beyond that baseline:
107
+
108
+ - use `@Passport.public()` only when anonymous access is intentional
109
+ - use `@Passport.systemAdmin()`, `@Passport.roleName(...)`, or a domain-specific guard when access needs additional restriction
110
+ - leave an action without a local Passport guard when authenticated-and-activated access is intentionally sufficient
111
+
112
+ The CRUD generator applies `@Passport.systemAdmin()` to each generated CRUD action rather than to the controller class. This keeps the administrative default explicit while allowing a future custom action to deliberately inherit the baseline or define its own policy.
113
+
102
114
  For the underlying auth, passport, and user-access model, see [Auth Guide](/backend/auth-guide) and [User Access Guide](/backend/user-access-guide).
103
115
 
104
116
  ## Interceptor
@@ -148,11 +148,11 @@ Representative pattern:
148
148
 
149
149
  ```typescript
150
150
  @Web.patch('updateUser/:id')
151
- updateUser(
151
+ async updateUser(
152
152
  @Arg.param('id') id: TableIdentity,
153
153
  @Arg.body(v.object(DtoUserUpdate)) user: DtoUserUpdate,
154
- ) {
155
- return this.scope.model.user.update(user, { where: { id } });
154
+ ): Promise<void> {
155
+ await this.scope.service.user.update(id, user);
156
156
  }
157
157
  ```
158
158
 
@@ -208,6 +208,48 @@ A practical rule is:
208
208
  - use return-type inference when the contract is obvious and simple
209
209
  - use explicit `@Api.body(...)` when the response shape needs more control or the inference boundary becomes unclear
210
210
 
211
+ ## Resource mutation response contract
212
+
213
+ Standard resource `update` and `delete` actions are command-style actions. Their public contract is successful completion, not an implicit persistence-model payload.
214
+
215
+ Representative pattern:
216
+
217
+ ```typescript
218
+ import { z } from 'zod';
219
+
220
+ @Web.patch(':id')
221
+ @Api.body(z.null())
222
+ @Passport.systemAdmin()
223
+ async update(
224
+ @Arg.param('id', v.tableIdentity()) id: TableIdentity,
225
+ @Arg.body() student: DtoStudentUpdate,
226
+ ): Promise<void> {
227
+ await this.scope.service.student.update(id, student);
228
+ }
229
+ ```
230
+
231
+ Use `await` without `return` at the controller boundary. A service or model may still return mutation data for internal orchestration, but that data is not automatically a stable public representation of the resource.
232
+
233
+ `Promise<void>` expresses the controller's command semantics, while `@Api.body(z.null())` explicitly supplies the response schema that runtime reflection cannot recover from `Promise<void>`. It makes the existing no-payload response visible to emitted OpenAPI and generated SDK consumers.
234
+
235
+ For a standard JSON action, Vona maps this no-result controller completion to its normal HTTP `200` success wrapper:
236
+
237
+ ```typescript
238
+ {
239
+ code: 0,
240
+ message: 'success',
241
+ data: null,
242
+ }
243
+ ```
244
+
245
+ This is Vona's default resource-mutation convention. It does not use HTTP `204 No Content`, so consumers can keep one uniform success-envelope protocol.
246
+
247
+ ### When a mutation should return data
248
+
249
+ Return data only when it is an intentional consumer-facing contract, such as an updated response DTO, a version token, or a job handle. Declare that shape explicitly with `@Api.body(...)`; add `@Core.serializer()` when the response depends on serializer transforms, exclusions, getters, or replacements. Do not forward raw ORM or service mutation results merely because the delegated method returns a value.
250
+
251
+ `create` and read actions commonly expose an identity or DTO and therefore use their own explicit response contracts. For generated resource defaults, see [CRUD Workflow](/backend/crud-workflow#generated-mutation-response-default). For action-level verification, see [Unit Testing](/backend/unit-testing#testing-controllers-through-actions). When a declared response shape changes, follow the [Backend OpenAPI to Frontend SDK](/fullstack/openapi-to-sdk) contract loop.
252
+
211
253
  ## Response wrapper behavior
212
254
 
213
255
  By default, Vona wraps the response body in a standard wrapper object.
@@ -43,6 +43,20 @@ The generator creates a connected set of files, typically including:
43
43
 
44
44
  This is exactly why this generator should be the default starting point. It gives a consistent starting shape across the backend thread.
45
45
 
46
+ ### Generated authorization default
47
+
48
+ The standard generated CRUD actions (`create`, `select`, `view`, `update`, and `delete`) each use `@Passport.systemAdmin()`. This is an explicit administrative whitelist for the generated Admin resource API, matching the generated SSR menu's `systemAdmin` visibility policy.
49
+
50
+ The guard is attached to individual actions rather than the controller class. When you add a custom action later, no local Passport guard intentionally means the global authenticated-and-activated baseline applies; it does not mean the action is public. Use `@Passport.public()` to intentionally allow anonymous access, or add a role/domain guard when an action needs stronger restriction.
51
+
52
+ SSR menu roles control navigation disclosure only. They never authorize a controller action, API, or resource; keep the action guard as the server-side authorization boundary. See [Controller AOP Guide](/backend/controller-aop-guide) and [Menu Guide](/backend/menu-guide).
53
+
54
+ ### Generated mutation response default
55
+
56
+ Generated resource `update` and `delete` actions are command-style actions. Their controllers use `Promise<void>`, await the service call without forwarding its result, and declare `@Api.body(z.null())` so OpenAPI and generated SDKs describe the normal HTTP `200` success wrapper accurately as `data: null`.
57
+
58
+ This controller-facing default does not require the service or model mutation method to return `void`. Those lower layers may retain mutation data for internal orchestration. See [Controller Guide](/backend/controller-guide#resource-mutation-response-contract) when a mutation deliberately needs a consumer-facing response payload.
59
+
46
60
  A practical generated-output checklist usually includes:
47
61
 
48
62
  - controller
@@ -84,6 +84,24 @@ A practical fallback rule is:
84
84
 
85
85
  - subdomain-based instance resolution only becomes relevant if custom getter, query-field, and header-field resolution did not already determine the instance name
86
86
 
87
+ ## Subdomain mapping and the default instance
88
+
89
+ `SERVER_SUBDOMAINOFFSET` controls how many labels at the right side of a hostname are treated as the base domain. Its default value is `2`, which fits a two-label base domain such as `cabloy.test` or `example.com`.
90
+
91
+ With that default, Vona derives the instance name from the remaining labels:
92
+
93
+ | Hostname | Derived instance name |
94
+ | --------------------- | --------------------- |
95
+ | `cabloy.test` | `''` |
96
+ | `acme.cabloy.test` | `acme` |
97
+ | `eu.acme.cabloy.test` | `acme.eu` |
98
+
99
+ The last example is intentionally `acme.eu`: Koa reverses the remaining hostname labels before Vona joins `ctx.subdomains` with `.`.
100
+
101
+ The empty name `''` is the configured default instance. It is not an implicit fallback for every hostname: it can be disabled or removed. Each non-empty subdomain-derived name, including a deeper name such as `acme.eu`, must refer to an explicitly configured and enabled instance. Requests for an unknown, disabled, or deleted instance do not fall back to the default instance.
102
+
103
+ If the public base domain uses a different label shape, set an explicit `SERVER_SUBDOMAINOFFSET` override that matches that domain before relying on subdomain instance resolution.
104
+
87
105
  ## Instance edge cases to remember
88
106
 
89
107
  A few current-runtime edge cases are worth remembering:
@@ -52,7 +52,6 @@ cd vona && npm run build
52
52
  cd vona && npm run build:docker
53
53
  cd vona && npm run start
54
54
  cd vona && npm run start:one
55
- cd vona && npm run start:docker
56
55
  ```
57
56
 
58
57
  In the current repo, those scripts map naturally to:
@@ -83,9 +82,10 @@ cd vona && npm run play
83
82
  cd vona && npm run build
84
83
  cd vona && npm run build:docker
85
84
  cd vona && npm run start
86
- cd vona && npm run start:docker
87
85
  ```
88
86
 
87
+ `build:docker` produces Docker-oriented output. Run that output through the Docker Compose workflow rather than a direct Vona `start:docker` command.
88
+
89
89
  These scripts show that the same `prod` mode can still branch into different output/runtime shapes through flavor.
90
90
 
91
91
  ## What the CLI injects automatically
@@ -52,14 +52,15 @@ From the root repository:
52
52
  npm run start
53
53
  ```
54
54
 
55
- Vona also supports single-process and docker-oriented start modes inside its own package:
55
+ Vona also supports its normal and single-process start modes inside its own package:
56
56
 
57
57
  ```bash
58
58
  cd vona && npm run start
59
59
  cd vona && npm run start:one
60
- cd vona && npm run start:docker
61
60
  ```
62
61
 
62
+ For Docker output, use the Docker Compose workflow after `npm run build:docker`; Docker does not use a direct Vona start command.
63
+
63
64
  ## Test and typecheck
64
65
 
65
66
  From the root repository:
@@ -121,13 +121,19 @@ A practical rule is:
121
121
  A representative contract-verification pattern is:
122
122
 
123
123
  ```typescript
124
- await app.bean.executor.performAction('patch', '/test/rest/product/:id', {
124
+ const updateRes = await app.bean.executor.performAction('patch', '/test/rest/product/:id', {
125
125
  params: { id: productId },
126
126
  body: dataUpdate,
127
127
  });
128
+ assert.equal(updateRes, null);
129
+
130
+ const deleteRes = await app.bean.executor.performAction('delete', '/test/rest/product/:id', {
131
+ params: { id: productId },
132
+ });
133
+ assert.equal(deleteRes, null);
128
134
  ```
129
135
 
130
- This is a good default because the same test can exercise params, body, route wiring, validation, and response behavior together.
136
+ For standard resource command mutations, these assertions verify the controller-facing no-payload runtime contract. Follow them with a query/read-back assertion to verify the persisted update or deletion. When the API is generated for frontend consumers, add a focused OpenAPI structural assertion that its wrapped response schema declares `data: null`; this verifies SDK-contract fidelity separately from responder behavior. The same test then exercises params, body, route wiring, validation, response behavior, and persistence effects together.
131
137
 
132
138
  ## Authentication simulation
133
139
 
@@ -17,9 +17,29 @@ Representative variables include:
17
17
  - `SSR_BODYREADYOBSERVER`
18
18
  - `SSR_API_BASE_URL`
19
19
  - `SSR_PROD_PORT`
20
+ - `SSR_TRANSFERCACHE`
21
+ - `SSR_TRANSFERCACHE_EXPIRES`
20
22
 
21
23
  These affect areas such as cookie-driven SSR behavior, theme defaults, body-load observation, server-side API targeting, and SSR production port behavior.
22
24
 
25
+ ## SSR response cache control
26
+
27
+ `SSR_TRANSFERCACHE` and `SSR_TRANSFERCACHE_EXPIRES` control the `Cache-Control` response header that Zova adds after an SSR page is rendered.
28
+
29
+ - `SSR_TRANSFERCACHE=false` disables this header path.
30
+ - Any other value enables it in the current configuration normalization.
31
+ - `SSR_TRANSFERCACHE_EXPIRES=0` emits `Cache-Control: no-cache, no-store, must-revalidate`.
32
+ - A positive number of seconds or an `ms`-style duration such as `10m` emits `Cache-Control: public, max-age=<seconds>`.
33
+
34
+ Cabloy Basic uses different flavor defaults:
35
+
36
+ | Flavor | Settings | SSR response header |
37
+ | ------ | --------------------------------------------------------- | ------------------------------------- |
38
+ | Web | `SSR_TRANSFERCACHE=true`, `SSR_TRANSFERCACHE_EXPIRES=10m` | `public, max-age=600` |
39
+ | Admin | `SSR_TRANSFERCACHE=true`, `SSR_TRANSFERCACHE_EXPIRES=0` | `no-cache, no-store, must-revalidate` |
40
+
41
+ A route can override the flavor default through its SSR route metadata. For Cloudflare cache-rule alignment that preserves these origin headers, see [Docker + Cloudflare Deployment](/fullstack/deploy-cloudflare-docker).
42
+
23
43
  ## Theme implications of `SSR_COOKIE`
24
44
 
25
45
  `SSR_COOKIE` is not only a storage choice. It also changes what SSR can guarantee about theme-sensitive output.
@@ -0,0 +1,136 @@
1
+ # Docker + Cloudflare Deployment
2
+
3
+ <Badge type="tip" text="Common" />
4
+
5
+ This guide deploys Cabloy Basic or Cabloy Start in two stages: first run the fullstack application on a cloud server with Docker Compose, then put Cloudflare orange-cloud proxying in front of that origin. It covers the Vona-integrated Zova Web and Admin SSR runtime shared by both editions.
6
+
7
+ Run all commands from the repository for the edition you are using. This guide documents the shared deployment contract; verify edition-specific flavor names, SSR site baselines, project assets, and repository details in the active edition source.
8
+
9
+ Cloudflare Pages and Cloudflare Workers are not supported deployment targets in this guide.
10
+
11
+ ## Deployment model
12
+
13
+ ```text
14
+ Browser
15
+ -> Cloudflare orange cloud
16
+ -> origin Nginx in Docker Compose
17
+ -> Vona app with Web/Admin SSR
18
+ -> PostgreSQL and Redis
19
+ ```
20
+
21
+ The Docker Compose origin is the application deployment. Cloudflare provides DNS, edge TLS, proxying, and response caching in front of that origin.
22
+
23
+ ## Stage 1: deploy the Docker origin
24
+
25
+ On the cloud server, clone and initialize the repository for your edition, then build and start its Docker flavor:
26
+
27
+ ```bash
28
+ npm run init
29
+ npm run build:docker
30
+ cd vona/docker-compose
31
+ sudo COMPOSE_BAKE=true docker-compose build
32
+ sudo docker-compose up -d
33
+ ```
34
+
35
+ The Compose stack contains the application, Nginx, PostgreSQL, and Redis. Treat database passwords, persistent volumes, backups, firewall rules, operating-system updates, and server hardening as production operator responsibilities.
36
+
37
+ In the repository for the edition you are using, `vona/docker-compose/` is the generated and ignored deployment directory, while `vona/docker-compose-original/` is the tracked source template. Initialization copies the template only when the generated directory does not already exist, so upgrading the repository does not automatically update an existing generated Nginx configuration. Reconcile or regenerate that local deployment configuration deliberately, and verify the current template in your edition repository.
38
+
39
+ ## Local preflight with `cabloy.test`
40
+
41
+ Before using a public domain, test the same hostname model locally. Use the reserved test domain `cabloy.test`, not a public domain such as `test.com`.
42
+
43
+ Map every hostname that you want to test in the local hosts file, or use local DNS. For example:
44
+
45
+ ```text
46
+ 127.0.0.1 cabloy.test acme.cabloy.test eu.acme.cabloy.test
47
+ ```
48
+
49
+ `SERVER_SUBDOMAINOFFSET` defaults to `2`. It treats the rightmost two labels as the base domain:
50
+
51
+ | Hostname | Derived instance |
52
+ | --------------------- | --------------------------- |
53
+ | `cabloy.test` | `''` (the default instance) |
54
+ | `acme.cabloy.test` | `acme` |
55
+ | `eu.acme.cabloy.test` | `acme.eu` |
56
+
57
+ The default instance is an explicitly configured empty-name instance. It can be disabled or removed. Each non-empty derived instance name must also be explicitly configured and enabled before its hostname can serve application traffic. A missing, disabled, or deleted named instance does not fall back to the default instance.
58
+
59
+ The Docker Nginx template preserves both the incoming `Host` and `X-Forwarded-Host`. This is necessary because Vona uses the forwarded host for host-sensitive URLs and subdomain instance resolution. Test through Nginx, not only by calling the app container directly.
60
+
61
+ For the instance configuration shape and full resolution order, see [Multi-Instance and Instance Resolution](/backend/multi-instance-and-instance-resolution).
62
+
63
+ ## Origin HTTPS prerequisite
64
+
65
+ Cloudflare TLS has two separate connections:
66
+
67
+ ```text
68
+ Visitor <-> Cloudflare edge
69
+ Cloudflare <-> origin Nginx
70
+ ```
71
+
72
+ The shared Docker origin baseline listens on port 80 only. Verify the current Nginx template in your edition repository. Before enabling Cloudflare **Full (strict)**, provide a separately managed TLS-capable origin layer and a valid certificate that matches the origin hostname. For example, this can be an Nginx or ingress configuration managed by the deployment environment.
73
+
74
+ Use Cloudflare **Full (strict)** for the production connection to the origin. Do not use Flexible mode: it leaves the Cloudflare-to-origin connection unencrypted and can produce incorrect scheme and security behavior. Enabling the orange cloud alone does not add HTTPS to the origin.
75
+
76
+ For current Cloudflare requirements, see [Full (strict)](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/).
77
+
78
+ ## Stage 2: put Cloudflare in front of the origin
79
+
80
+ After the origin is reachable with its TLS configuration:
81
+
82
+ 1. Create the DNS record for the base hostname, such as `example.com`, pointing to the origin server.
83
+ 2. Enable the record's orange-cloud proxy status.
84
+ 3. If subdomain instance routing is required, add the required instance records or a suitable wildcard record, such as `*.example.com`.
85
+ 4. Keep the origin proxy configuration forwarding the original `Host` and `X-Forwarded-Host` values to Vona.
86
+ 5. Set the deployment environment to the public URL when it must produce canonical absolute URLs:
87
+
88
+ ```dotenv
89
+ SERVER_SERVE_PROTOCOL=https
90
+ SERVER_SERVE_HOST=example.com
91
+ SERVER_SUBDOMAINOFFSET=2
92
+ ```
93
+
94
+ The base hostname resolves to the empty-name/default instance. For example, `acme.example.com` resolves to `acme`; a deeper hostname such as `eu.acme.example.com` resolves to `acme.eu` and requires that explicitly configured instance.
95
+
96
+ For current Cloudflare proxy-record behavior, see [Proxied DNS records](https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/).
97
+
98
+ ## Cloudflare Cache Rule
99
+
100
+ Configure one Cache Rule for the SSR origin:
101
+
102
+ 1. **If incoming requests match:** All incoming requests.
103
+ 2. **Cache eligibility:** Eligible for cache.
104
+ 3. **Edge TTL:** Use the origin `Cache-Control` header when present; bypass cache when it is absent.
105
+
106
+ Do not add another Cache Rule or response-header override that replaces the origin `Cache-Control` value for these routes.
107
+
108
+ This rule is intentionally small. It makes a response eligible for caching but still lets the SSR response header decide whether Cloudflare stores it. A `no-store` response remains non-cacheable, and normal Cloudflare cacheability safeguards still apply.
109
+
110
+ For current Cache Rule settings, see [Cache Rules settings](https://developers.cloudflare.com/cache/how-to/cache-rules/settings/).
111
+
112
+ ## SSR cache contract
113
+
114
+ Zova SSR writes the cache contract during rendering through `SSR_TRANSFERCACHE` and `SSR_TRANSFERCACHE_EXPIRES`. The current public Cabloy Basic baseline uses these defaults:
115
+
116
+ | Flavor | Default settings | SSR response header | Cloudflare result with this rule |
117
+ | ------ | --------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
118
+ | Web | `SSR_TRANSFERCACHE=true`, `SSR_TRANSFERCACHE_EXPIRES=10m` | `Cache-Control: public, max-age=600` | Eligible to cache for ten minutes, subject to Cloudflare policy |
119
+ | Admin | `SSR_TRANSFERCACHE=true`, `SSR_TRANSFERCACHE_EXPIRES=0` | `Cache-Control: no-cache, no-store, must-revalidate` | Not stored |
120
+
121
+ For Cabloy Start, verify the effective Web and Admin values in the licensed Start repository before creating the Cloudflare rule. The rule design remains the same: preserve and follow the origin `Cache-Control` response instead of replacing it.
122
+
123
+ A route can override the flavor default through SSR route metadata. The Cloudflare rule follows the response contract; it does not replace it.
124
+
125
+ For the public Basic environment-variable details, see [SSR Environment Variables](/frontend/ssr-env).
126
+
127
+ ## Deployment checks
128
+
129
+ After the origin is running, verify the following before relying on Cloudflare traffic:
130
+
131
+ 1. The base hostname returns the expected default instance.
132
+ 2. Each configured subdomain resolves to its expected enabled instance.
133
+ 3. Nginx forwards the browser hostname instead of the literal value `localhost`.
134
+ 4. Web SSR returns the `Cache-Control` header expected from the active edition and flavor configuration. In the current Basic baseline, it is `public, max-age=600`.
135
+ 5. Admin SSR returns the `Cache-Control` header expected from the active edition and flavor configuration. In the current Basic baseline, it is `no-cache, no-store, must-revalidate`.
136
+ 6. After Cloudflare proxying is enabled, responses intended to be public-cacheable can be cached at the edge while responses marked `no-store` are not stored.
@@ -68,6 +68,7 @@ For the deeper frontend perspective, see:
68
68
  If you want the shortest accurate mental model, use this forward chain:
69
69
 
70
70
  1. backend controller signatures define request and response entry surfaces
71
+ - command-style mutations that complete with Vona's wrapped `data: null` must declare `@Api.body(z.null())`; `Promise<void>` alone cannot preserve that generic type through runtime reflection
71
72
  2. backend DTO and entity fields shape named and shared contract structure
72
73
  3. validation rules and `v` helpers refine the machine-readable contract
73
74
  4. Vona emits OpenAPI output from those backend declarations
@@ -95,6 +95,9 @@ As you inspect the generated files, pay attention to the division of responsibil
95
95
 
96
96
  This is the backend contract thread that later tutorials will extend with `level`, `mobile`, render metadata, OpenAPI output, and row actions.
97
97
 
98
+ > [!NOTE]
99
+ > The generated Admin CRUD actions explicitly require the `systemAdmin` role. If this resource should serve another audience, adjust the relevant controller action guards as well as any SSR menu visibility; changing the menu alone does not authorize an API.
100
+
98
101
  ## Verification
99
102
 
100
103
  1. make sure the local dev workflow is running:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.106",
3
+ "version": "5.1.108",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -12,7 +12,7 @@ server {
12
12
  proxy_http_version 1.1;
13
13
  proxy_set_header X-Real-IP $remote_addr;
14
14
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
15
- proxy_set_header X-Forwarded-Host $server_name;
15
+ proxy_set_header X-Forwarded-Host $http_host;
16
16
  proxy_set_header X-Forwarded-Proto $scheme;
17
17
  proxy_set_header Host $http_host;
18
18
  proxy_set_header X-NginX-Proxy true;
package/vona/env/.env CHANGED
@@ -9,7 +9,7 @@ APP_VERSION = 5.0.0
9
9
  SERVER_KEYS = vona__1596889047267_3245
10
10
  SERVER_GLOBALPREFIX = /api
11
11
  SERVER_PUBLICDIR =
12
- SERVER_SUBDOMAINOFFSET = 1
12
+ SERVER_SUBDOMAINOFFSET = 2
13
13
  SERVER_WORKERS =
14
14
  SERVER_LISTEN_HOSTNAME = 0.0.0.0
15
15
  SERVER_LISTEN_PORT = 7102
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.129",
3
+ "version": "1.1.130",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -20,6 +20,7 @@ export interface ISsrMenuOptions<%=argv.resourceNameCapitalize%> extends IDecora
20
20
  },
21
21
  },
22
22
  group: '<%=argv.ssrSiteGroupName%>',
23
+ roles: ['systemAdmin'],
23
24
  },
24
25
  },
25
26
  site: ['<%=argv.ssrSiteOnionName%>'],
@@ -4,6 +4,8 @@ import type { IDecoratorControllerOptions } from 'vona-module-a-web';
4
4
 
5
5
  import { BeanBase } from 'vona';
6
6
  import { Api, Resource, v } from 'vona-module-a-openapiutils';
7
+ import { z } from 'zod';
8
+ import { Passport } from 'vona-module-a-user';
7
9
  import { Arg, Controller, Web } from 'vona-module-a-web';
8
10
 
9
11
  import type { Model<%=argv.resourceNameCapitalize%> } from '../model/<%=argv.resourceName%>.ts';
@@ -21,29 +23,36 @@ export interface IControllerOptions<%=argv.resourceNameCapitalize%> extends IDec
21
23
  export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
22
24
  @Web.post()
23
25
  @Api.body(v.tableIdentity())
26
+ @Passport.systemAdmin()
24
27
  async create(@Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Create): Promise<TableIdentity> {
25
28
  return (await this.scope.service.<%=argv.resourceName%>.create(<%=argv.resourceName%>)).id;
26
29
  }
27
30
 
28
31
  @Web.get()
29
32
  @Api.body(Dto<%=argv.resourceNameCapitalize%>SelectRes)
33
+ @Passport.systemAdmin()
30
34
  async select(@Arg.filter(Dto<%=argv.resourceNameCapitalize%>SelectReq) params: IQueryParams<Model<%=argv.resourceNameCapitalize%>>): Promise<Dto<%=argv.resourceNameCapitalize%>SelectRes> {
31
35
  return await this.scope.service.<%=argv.resourceName%>.select(params);
32
36
  }
33
37
 
34
38
  @Web.get(':id')
35
39
  @Api.body(v.optional(), v.object(Dto<%=argv.resourceNameCapitalize%>View))
40
+ @Passport.systemAdmin()
36
41
  async view(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<Dto<%=argv.resourceNameCapitalize%>View | undefined> {
37
42
  return await this.scope.service.<%=argv.resourceName%>.view(id);
38
43
  }
39
44
 
40
45
  @Web.patch(':id')
41
- async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update) {
42
- return await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
46
+ @Api.body(z.null())
47
+ @Passport.systemAdmin()
48
+ async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update): Promise<void> {
49
+ await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
43
50
  }
44
51
 
45
52
  @Web.delete(':id')
46
- async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity) {
47
- return await this.scope.service.<%=argv.resourceName%>.delete(id);
53
+ @Api.body(z.null())
54
+ @Passport.systemAdmin()
55
+ async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
56
+ await this.scope.service.<%=argv.resourceName%>.delete(id);
48
57
  }
49
58
  }
@@ -15,7 +15,24 @@ describe('<%=argv.resourceName%>.test.ts', () => {
15
15
  name: '__TomNew__',
16
16
  description: 'This is a test',
17
17
  };
18
- // login
18
+ // role-less authenticated users cannot access generated admin actions
19
+ await app.bean.passport.signinMock();
20
+ 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));
32
+ } finally {
33
+ await app.bean.passport.signout();
34
+ }
35
+ // login as system admin
19
36
  await app.bean.passport.signinMock();
20
37
  // create
21
38
  const <%=argv.resourceName%>Id = await app.bean.executor.performAction('post', '<%=argv.moduleActionPathRaw%>', { body: data });
@@ -24,15 +41,17 @@ describe('<%=argv.resourceName%>.test.ts', () => {
24
41
  const selectRes: Dto<%=argv.resourceNameCapitalize%>SelectRes = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>');
25
42
  assert.equal(selectRes.list.findIndex(item => item.name === data.name) > -1, true);
26
43
  // update
27
- await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
44
+ const updateRes = await app.bean.executor.performAction('patch', '<%=argv.moduleActionPathRaw%>/:id', {
28
45
  params: { id: <%=argv.resourceName%>Id },
29
46
  body: dataUpdate,
30
47
  });
48
+ assert.equal(updateRes, null);
31
49
  // findOne
32
50
  let <%=argv.resourceName%>: Entity<%=argv.resourceNameCapitalize%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>Id } });
33
51
  assert.equal(<%=argv.resourceName%>.name, dataUpdate.name);
34
52
  // delete
35
- await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
53
+ const deleteRes = await app.bean.executor.performAction('delete', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
54
+ assert.equal(deleteRes, null);
36
55
  // findOne
37
56
  <%=argv.resourceName%> = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/:id', { params: { id: <%=argv.resourceName%>.id } });
38
57
  assert.equal(<%=argv.resourceName%>, undefined);
@@ -4,6 +4,8 @@ import type { IDecoratorControllerOptions } from 'vona-module-a-web';
4
4
 
5
5
  import { BeanBase } from 'vona';
6
6
  import { Api, Resource, v } from 'vona-module-a-openapiutils';
7
+ import { z } from 'zod';
8
+ import { Passport } from 'vona-module-a-user';
7
9
  import { Arg, Controller, Web } from 'vona-module-a-web';
8
10
 
9
11
  import type { Model<%=argv.resourceNameCapitalize%> } from '../model/<%=argv.resourceName%>.ts';
@@ -21,29 +23,36 @@ export interface IControllerOptions<%=argv.resourceNameCapitalize%> extends IDec
21
23
  export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
22
24
  @Web.post()
23
25
  @Api.body(v.tableIdentity())
26
+ @Passport.systemAdmin()
24
27
  async create(@Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Create): Promise<TableIdentity> {
25
28
  return (await this.scope.service.<%=argv.resourceName%>.create(<%=argv.resourceName%>)).id;
26
29
  }
27
30
 
28
31
  @Web.get()
29
32
  @Api.body(Dto<%=argv.resourceNameCapitalize%>SelectRes)
33
+ @Passport.systemAdmin()
30
34
  async select(@Arg.filter(Dto<%=argv.resourceNameCapitalize%>SelectReq) params: IQueryParams<Model<%=argv.resourceNameCapitalize%>>): Promise<Dto<%=argv.resourceNameCapitalize%>SelectRes> {
31
35
  return await this.scope.service.<%=argv.resourceName%>.select(params);
32
36
  }
33
37
 
34
38
  @Web.get(':id')
35
39
  @Api.body(v.optional(), v.object(Dto<%=argv.resourceNameCapitalize%>View))
40
+ @Passport.systemAdmin()
36
41
  async view(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<Dto<%=argv.resourceNameCapitalize%>View | undefined> {
37
42
  return await this.scope.service.<%=argv.resourceName%>.view(id);
38
43
  }
39
44
 
40
45
  @Web.patch(':id')
41
- async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update) {
42
- return await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
46
+ @Api.body(z.null())
47
+ @Passport.systemAdmin()
48
+ async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update): Promise<void> {
49
+ await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
43
50
  }
44
51
 
45
52
  @Web.delete(':id')
46
- async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity) {
47
- return await this.scope.service.<%=argv.resourceName%>.delete(id);
53
+ @Api.body(z.null())
54
+ @Passport.systemAdmin()
55
+ async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
56
+ await this.scope.service.<%=argv.resourceName%>.delete(id);
48
57
  }
49
58
  }