cabloy 5.1.193 → 5.1.194

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 (30) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/adapters/codex/contract-loop-gate.mjs +1 -1
  5. package/repo-agent-governance/adapters/cursor/contract-loop-gate.mjs +1 -1
  6. package/repo-agent-governance/managed-assets.json +2 -2
  7. package/repo-agent-governance/tests/governance.test.mjs +32 -0
  8. package/repo-agent-governance/tools/contract-loop/hook-runtime.mjs +4 -3
  9. package/repo-docs/backend/menu-authorization.md +1 -0
  10. package/repo-docs/backend/menu-guide.md +18 -7
  11. package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -0
  12. package/repo-e2e/config/playwright.config.ts +1 -1
  13. package/repo-e2e/scripts/startE2eVona.ts +6 -2
  14. package/repo-e2e/specs/controller-boundary.spec.ts +76 -28
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  17. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/beanSsrSiteBase.ts +2 -0
  18. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenu.ts +9 -3
  19. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuEligibility.ts +14 -0
  20. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuGroup.ts +9 -3
  21. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuSite.ts +10 -0
  22. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenu.ts +1 -1
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenuGroup.ts +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuEligibility.test.ts +73 -13
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuSite.test.ts +90 -0
  26. package/vona/src/suite-vendor/a-cabloy/package.json +1 -1
  27. package/vona/src/suite-vendor/a-vona/modules/a-error/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-error/src/lib/onerror.ts +9 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-error/test/onerror.test.ts +92 -0
  30. package/vona/src/suite-vendor/a-vona/package.json +1 -1
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.193
1
+ 5.1.194
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.194
4
+
5
+ ### Bug Fixes
6
+
7
+ - Resolve lint errors in governance contract-loop hooks.
8
+
9
+ ### Improvements
10
+
11
+ - Update controller-boundary tests.
12
+
13
+ ### Breaking Changes
14
+
15
+ - Require explicit menu site bindings for SSR.
16
+
3
17
  ## 5.1.193
4
18
 
5
19
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.193",
3
+ "version": "5.1.194",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -17,7 +17,7 @@ function runCodexHook() {
17
17
  const evaluated = evaluateCodexEditedFiles(ROOT, extractCodexEditedFiles(payload), payload.cwd);
18
18
  if (!evaluated.length) return 0;
19
19
  const message = evaluated.map(item => item.message).join('\n\n');
20
- // eslint-disable-next-line
20
+ // eslint-disable-next-line no-console
21
21
  console.log(JSON.stringify(formatCodexHookOutput(message)));
22
22
  return 0;
23
23
  }
@@ -19,7 +19,7 @@ function runCursorHook() {
19
19
  if (!evaluated) return 0;
20
20
  const output = formatCursorHookOutput(evaluated.message, inferCursorHookEvent(payload));
21
21
  if (output) {
22
- // eslint-disable-next-line
22
+ // eslint-disable-next-line no-console
23
23
  console.log(JSON.stringify(output));
24
24
  }
25
25
  return 0;
@@ -592,7 +592,7 @@
592
592
  {
593
593
  "adapter": "codex",
594
594
  "category": "runtime",
595
- "sha256": "3f3fcc1824f18bcdc2040d65932a47cb74b477ad21670dae957de61540af0154",
595
+ "sha256": "7d7bc4472cd7fdfa2a34193117a771445b3b2ac6fddb553c64050f7ae9cf01cc",
596
596
  "source": "adapters/codex/contract-loop-gate.mjs",
597
597
  "target": ".codex/hooks/contract-loop-gate.mjs"
598
598
  },
@@ -606,7 +606,7 @@
606
606
  {
607
607
  "adapter": "cursor",
608
608
  "category": "runtime",
609
- "sha256": "da224fb581fde615273af8db4e2bfca513233a83e69a0803442dfec86bbd6ba6",
609
+ "sha256": "14ec4e6aaf57cd8cdc21b42b8ff865810d79a82f0927a4445b756e41c0e2438a",
610
610
  "source": "adapters/cursor/contract-loop-gate.mjs",
611
611
  "target": ".cursor/hooks/contract-loop-gate.mjs"
612
612
  },
@@ -367,6 +367,38 @@ test('contract-loop recognizes supported vendor and suite source paths', () => {
367
367
  assert.equal(isCodeFile('/repo/repo-docs/ai/agent-governance.md'), false);
368
368
  });
369
369
 
370
+ test('Codex patch headers preserve operations, moves, and paths with whitespace', () => {
371
+ const command = [
372
+ '*** Begin Patch',
373
+ '*** Add File:\t vona/src/module/demo/src/dto/new item.ts ',
374
+ '*** Update File: vona/src/module/demo/src/service/old.ts',
375
+ '*** Move to:\t vona/src/module/demo/src/controller/moved item.ts \t',
376
+ '@@',
377
+ '+*** Delete File: not-a-header.ts',
378
+ '*** Delete File: vona/src/module/demo/src/dto/deleted.ts',
379
+ '*** End Patch',
380
+ ].join('\r\n');
381
+ assert.deepEqual(extractCodexEditedFiles({ tool_input: { command } }), [
382
+ { operation: 'add', filePath: 'vona/src/module/demo/src/dto/new item.ts' },
383
+ { operation: 'update', filePath: 'vona/src/module/demo/src/controller/moved item.ts' },
384
+ { operation: 'delete', filePath: 'vona/src/module/demo/src/dto/deleted.ts' },
385
+ ]);
386
+ });
387
+
388
+ test('Codex blank file headers do not consume subsequent patch lines', () => {
389
+ const command = [
390
+ '*** Add File:',
391
+ `*** Delete File:${' '.repeat(100_000)}`,
392
+ '*** Update File:\t ',
393
+ '*** Add File: vona/src/module/demo/src/dto/valid.ts',
394
+ ].join('\n');
395
+ assert.deepEqual(extractCodexEditedFiles({ tool_input: { command } }), [
396
+ { operation: 'add', filePath: 'vona/src/module/demo/src/dto/valid.ts' },
397
+ ]);
398
+ assert.deepEqual(extractCodexEditedFiles({}), []);
399
+ assert.deepEqual(extractCodexEditedFiles({ tool_input: { command: null } }), []);
400
+ });
401
+
370
402
  test('Codex contract-loop adapter evaluates operation-aware apply_patch paths', () => {
371
403
  withFixture(root => {
372
404
  const sourcePath = resolve(root, 'vona/src/module/demo/src/controller/demo.ts');
@@ -176,15 +176,16 @@ export function extractCodexEditedFiles(payload) {
176
176
  if (typeof command !== 'string') return [];
177
177
  const entries = [];
178
178
  let pendingUpdate = null;
179
- const headerPattern =
180
- /^\*\*\* (Add|Update|Delete) File:\s*(.+?)\s*$|^\*\*\* Move to:\s*(.+?)\s*$/gm;
179
+ const headerPattern = /^\*\*\* (Add|Update|Delete) File:(.+)$|^\*\*\* Move to:(.+)$/;
181
180
 
182
181
  const flushUpdate = () => {
183
182
  if (pendingUpdate) entries.push(pendingUpdate);
184
183
  pendingUpdate = null;
185
184
  };
186
185
 
187
- for (const match of command.matchAll(headerPattern)) {
186
+ for (const line of command.split(/\r?\n/)) {
187
+ const match = headerPattern.exec(line);
188
+ if (!match) continue;
188
189
  const [, verb, sourcePath, destinationPath] = match;
189
190
  if (destinationPath !== undefined) {
190
191
  if (pendingUpdate) pendingUpdate.filePath = destinationPath.trim();
@@ -18,6 +18,7 @@ An SSR menu item can declare `roles` metadata:
18
18
  link: 'presetResource',
19
19
  roles: ['systemAdmin'],
20
20
  },
21
+ site: 'basic-siteadmin:admin',
21
22
  })
22
23
  ```
23
24
 
@@ -57,9 +57,16 @@ In the current repo implementation, the out-of-the-box menu controller is public
57
57
 
58
58
  ## SSR Site targeting
59
59
 
60
- `@SsrMenu(...)` and `@SsrMenuGroup(...)` use `site` to select the SSR Site onion(s) that receive the declaration. It is a navigation-composition filter, not a site-admission, role-visibility, or backend-authorization decision.
60
+ `@SsrMenu(...)` and `@SsrMenuGroup(...)` require an explicit `site` to select the SSR Site onion(s) that receive the declaration. It is a navigation-composition filter, not a site-admission, role-visibility, or backend-authorization decision.
61
61
 
62
- Use `site` for navigation owned by one SSR Site or deliberately exclusive to a known subset of sites:
62
+ - Use a site string or an array of site strings to target known SSR Sites.
63
+ - Omission, `undefined`, `null`, and empty or whitespace-only strings are invalid, including blank entries in an array.
64
+ - `site: []` is valid and explicitly means no sites receive the declaration.
65
+ - Sharing requires an explicit array of intended sites; there is no wildcard or implicit all-sites scope.
66
+ - Menu and group declarations are independently scoped. A menu does not inherit its group's `site`; declare the intended sites on both.
67
+ - `locale` remains optional: omitting it leaves the declaration unrestricted by locale.
68
+
69
+ Use a single site string for navigation owned by one SSR Site:
63
70
 
64
71
  ```typescript
65
72
  @SsrMenu({
@@ -69,11 +76,11 @@ Use `site` for navigation owned by one SSR Site or deliberately exclusive to a k
69
76
  roles: ['systemAdmin'],
70
77
  },
71
78
  // This Basic Admin-owned entry intentionally targets one SSR Site.
72
- site: ['basic-siteadmin:admin'],
79
+ site: 'basic-siteadmin:admin',
73
80
  })
74
81
  ```
75
82
 
76
- For a shared module declaration intended for every compatible SSR Admin site, omit `site`:
83
+ For a shared module declaration, list every intended compatible SSR Admin site explicitly:
77
84
 
78
85
  ```typescript
79
86
  @SsrMenu({
@@ -82,11 +89,15 @@ For a shared module declaration intended for every compatible SSR Admin site, om
82
89
  link: 'presetResource',
83
90
  roles: ['systemAdmin'],
84
91
  },
85
- // No `site`: the shared declaration is available to compatible SSR Admin sites.
92
+ // Share only with these explicitly selected SSR Sites.
93
+ site: ['basic-siteadmin:admin', 'commerce-siteadmin:commerceAdmin'],
86
94
  })
87
95
  ```
88
96
 
89
- Do not bind a shared Admin menu or menu group to the current/default Admin site merely to keep a new independent site's navigation narrow. Isolate an independent site through its enabled module composition and site-owned menus/groups. When a shared capability needs genuinely different navigation contracts per site, use an explicit, approved site-specific composition or owner instead of narrowing the shared declaration by default.
97
+ Choose the site list from the intended navigation contract, not merely the current/default Admin site. Isolate an independent site through its enabled module composition and site-owned menus/groups. When a shared capability needs genuinely different navigation contracts per site, use an explicit, approved site-specific composition or owner.
98
+
99
+ > [!WARNING]
100
+ > This is a breaking change for declarations that previously omitted `site` to reach all sites. Migrate each menu and group to an explicit site string or array, or use `site: []` when it should reach no sites. Add future shared sites to the array deliberately; omission is no longer a sharing mechanism.
90
101
 
91
102
  `site` is distinct from a role's `siteIds`, menu `roles` or role-menu associations, and Passport/RBAC authorization:
92
103
 
@@ -140,6 +151,6 @@ When editing SSR menu behavior, ask:
140
151
  2. is there a default fallback menu that should remain available?
141
152
  3. does the menu contract belong in backend API design, frontend route design, or both?
142
153
  4. does the active edition affect the menu structure or public path assumptions?
143
- 5. is this declaration site-owned or deliberately site-exclusive (specify `site`), or shared across compatible SSR Admin sites (omit `site`)?
154
+ 5. does every menu and group declare its own explicit `site` string or array, with all intended shared sites listed and `[]` used only when no sites should receive it?
144
155
 
145
156
  That helps AI keep menu behavior aligned with Cabloy’s shared SSR architecture.
@@ -219,6 +219,7 @@ The Student SSR menu supplies that context:
219
219
  },
220
220
  },
221
221
  },
222
+ site: ['basic-siteadmin:admin'],
222
223
  })
223
224
  export class SsrMenuStudent extends BeanBase {}
224
225
  ```
@@ -21,7 +21,7 @@ export default defineConfig({
21
21
  : {
22
22
  command: 'node repo-e2e/scripts/startE2eVona.ts',
23
23
  cwd: E2E_ROOT_DIR,
24
- url: `${baseURL}/`,
24
+ url: `${baseURL}/health/ready`,
25
25
  timeout: 180_000,
26
26
  reuseExistingServer: false,
27
27
  stdout: 'pipe',
@@ -2,13 +2,17 @@ import type { ChildProcess } from 'node:child_process';
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
4
 
5
- import { E2E_ROOT_DIR } from './e2e.ts';
5
+ import { E2E_LOCAL_BASE_URL, E2E_ROOT_DIR } from './e2e.ts';
6
6
 
7
7
  const child: ChildProcess = spawn('npm', ['run', 'dev:one'], {
8
8
  cwd: E2E_ROOT_DIR,
9
9
  detached: process.platform !== 'win32',
10
10
  stdio: 'inherit',
11
- env: process.env,
11
+ env: {
12
+ ...process.env,
13
+ SERVER_SERVE_PROTOCOL: 'http',
14
+ SERVER_SERVE_HOST: E2E_LOCAL_BASE_URL.replace(/^https?:\/\//, ''),
15
+ },
12
16
  });
13
17
 
14
18
  const gracefulShutdownTimeout = 7000;
@@ -4,6 +4,29 @@ import { expect, test } from '@playwright/test';
4
4
 
5
5
  const probeErrorMessage = 'Controller boundary probe initialization failed';
6
6
 
7
+ async function pauseProbeClock(page: Page) {
8
+ await page.clock.pauseAt(await page.evaluate(() => Date.now() + 60_000));
9
+ // Wait for __init__ to schedule its timer, not merely for the boundary timer to start.
10
+ const timers = await page.evaluateHandle(() => {
11
+ const scheduled: Record<number, number> = {};
12
+ window.setTimeout = new Proxy(window.setTimeout, {
13
+ apply(target, receiver, args) {
14
+ const delay = args[1];
15
+ scheduled[delay] = (scheduled[delay] ?? 0) + 1;
16
+ return Reflect.apply(target, receiver, args);
17
+ },
18
+ });
19
+ return scheduled;
20
+ });
21
+ return async (delay: number, action: () => Promise<unknown>) => {
22
+ const before = await timers.evaluate((scheduled, delay) => scheduled[delay] ?? 0, delay);
23
+ await action();
24
+ await expect
25
+ .poll(() => timers.evaluate((scheduled, delay) => scheduled[delay] ?? 0, delay))
26
+ .toBe(before + 1);
27
+ };
28
+ }
29
+
7
30
  function collectPageErrors(page: Page) {
8
31
  const errors: Error[] = [];
9
32
  page.on('pageerror', error => {
@@ -26,6 +49,7 @@ test(
26
49
  'ATP-BASIC-CONTROLLER-BOUNDARY-01: global boundaries render loading, error, and recovery',
27
50
  { tag: ['@web', '@flow'] },
28
51
  async ({ page, request }) => {
52
+ await page.clock.install();
29
53
  const serverResponse = await request.get('/demo/basic/controllerBoundary');
30
54
  expect(serverResponse.ok()).toBeTruthy();
31
55
  const serverHtml = await serverResponse.text();
@@ -50,48 +74,63 @@ test(
50
74
  await expect(error).toHaveCount(0);
51
75
  await expect(ready).toHaveCount(0);
52
76
 
53
- await page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click();
54
- await page.waitForTimeout(400);
77
+ const startProbe = await pauseProbeClock(page);
78
+ await startProbe(300, () =>
79
+ page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click(),
80
+ );
81
+ await page.clock.runFor(400);
55
82
  await expect(loading).toHaveCount(0);
56
83
  await expect(ready).toBeVisible();
57
84
  await expect(error).toHaveCount(0);
58
85
 
59
- await page.getByRole('button', { name: 'Mount fast failing probe', exact: true }).click();
60
- await page.waitForTimeout(400);
86
+ await startProbe(300, () =>
87
+ page.getByRole('button', { name: 'Mount fast failing probe', exact: true }).click(),
88
+ );
89
+ await page.clock.runFor(400);
61
90
  await expect(error).toHaveText(probeErrorMessage);
62
91
  await expect(loading).toHaveCount(0);
63
92
  await expect(ready).toHaveCount(0);
64
93
 
65
- const delayedSuccessLoading = loading.waitFor({ state: 'visible' });
66
- await page.getByRole('button', { name: 'Mount delayed success probe', exact: true }).click();
67
- await delayedSuccessLoading;
94
+ await startProbe(800, () =>
95
+ page.getByRole('button', { name: 'Mount delayed success probe', exact: true }).click(),
96
+ );
97
+ await page.clock.runFor(600);
98
+ await expect(loading).toBeVisible();
99
+ await page.clock.runFor(400);
68
100
  await expect(ready).toBeVisible();
69
101
  await expect(loading).toHaveCount(0);
70
102
  await expect(error).toHaveCount(0);
71
103
 
72
- const failingProbeLoading = loading.waitFor({ state: 'visible' });
73
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
74
- await failingProbeLoading;
104
+ await startProbe(800, () =>
105
+ page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
106
+ );
107
+ await page.clock.runFor(600);
108
+ await expect(loading).toBeVisible();
75
109
  await expect.poll(() => loading.evaluate(element => element.tagName)).toBe('DIV');
110
+ await page.clock.runFor(400);
76
111
  await expect(error).toHaveText(probeErrorMessage);
77
112
  await expect.poll(() => error.evaluate(element => element.tagName)).toBe('DIV');
78
113
  await expect(ready).toHaveCount(0);
79
114
  const retry = page.getByRole('button', { name: 'Retry', exact: true });
80
115
  await expect(retry).toBeVisible();
81
116
 
82
- const failedRetryLoading = loading.waitFor({ state: 'visible' });
83
- await retry.click();
84
- await failedRetryLoading;
117
+ await startProbe(800, () => retry.click());
118
+ await page.clock.runFor(600);
119
+ await expect(loading).toBeVisible();
120
+ await page.clock.runFor(400);
85
121
  await expect(error).toHaveText(probeErrorMessage);
86
122
  await expect(retry).toBeVisible();
87
123
 
88
124
  await page.getByRole('button', { name: 'Prepare probe retry success', exact: true }).click();
89
- const successfulRetryLoading = loading.waitFor({ state: 'visible' });
90
- await retry.evaluate(button => {
91
- button.click();
92
- button.click();
93
- });
94
- await successfulRetryLoading;
125
+ await startProbe(800, () =>
126
+ retry.evaluate(button => {
127
+ button.click();
128
+ button.click();
129
+ }),
130
+ );
131
+ await page.clock.runFor(600);
132
+ await expect(loading).toBeVisible();
133
+ await page.clock.runFor(400);
95
134
  await expect(ready).toBeVisible();
96
135
  await expect(loading).toHaveCount(0);
97
136
  await expect(error).toHaveCount(0);
@@ -105,6 +144,7 @@ test(
105
144
  'ATP-BASIC-CONTROLLER-BOUNDARY-06: disposed and stale loads do not surface errors',
106
145
  { tag: ['@web', '@flow'] },
107
146
  async ({ page }) => {
147
+ await page.clock.install();
108
148
  const pageErrors = collectPageErrors(page);
109
149
  const consoleErrors = collectConsoleErrors(page);
110
150
  const response = await page.goto('/demo/basic/controllerBoundary', { waitUntil: 'load' });
@@ -116,21 +156,29 @@ test(
116
156
  const error = page.locator('.alert.alert-error');
117
157
  const ready = page.getByText('Controller boundary probe ready', { exact: true });
118
158
 
119
- const disposedLoading = loading.waitFor({ state: 'visible' });
120
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
121
- await disposedLoading;
159
+ const startProbe = await pauseProbeClock(page);
160
+ await startProbe(800, () =>
161
+ page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
162
+ );
163
+ await page.clock.runFor(600);
164
+ await expect(loading).toBeVisible();
122
165
  await page.getByRole('button', { name: 'Clear probe', exact: true }).click();
123
- await page.waitForTimeout(1_000);
166
+ await page.clock.runFor(1_000);
124
167
  await expect(loading).toHaveCount(0);
125
168
  await expect(error).toHaveCount(0);
126
169
  await expect(ready).toHaveCount(0);
127
170
 
128
- const staleLoading = loading.waitFor({ state: 'visible' });
129
- await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
130
- await staleLoading;
131
- await page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click();
171
+ await startProbe(800, () =>
172
+ page.getByRole('button', { name: 'Mount failing probe', exact: true }).click(),
173
+ );
174
+ await page.clock.runFor(600);
175
+ await expect(loading).toBeVisible();
176
+ await startProbe(300, () =>
177
+ page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click(),
178
+ );
179
+ await page.clock.runFor(1_000);
132
180
  await expect(ready).toBeVisible();
133
- await page.waitForTimeout(1_000);
181
+ await page.clock.runFor(1_000);
134
182
  await expect(error).toHaveCount(0);
135
183
  await expect(ready).toBeVisible();
136
184
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona",
3
- "version": "5.1.101",
3
+ "version": "5.1.102",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ssr",
3
- "version": "5.1.33",
3
+ "version": "5.1.34",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -36,6 +36,7 @@ import { ServiceDevProxy } from '../service/devProxy.ts';
36
36
  import { ServiceSsrHandler } from '../service/ssrHandler.ts';
37
37
  import { SymbolCacheMenus } from './const.ts';
38
38
  import { checkSsrBinding } from './ssrMenuEligibility.ts';
39
+ import { validateSsrMenuSite } from './ssrMenuSite.ts';
39
40
  import {
40
41
  projectPublicSsrMenuGroups,
41
42
  projectPublicSsrMenus,
@@ -360,6 +361,7 @@ export class BeanSsrSiteBase<
360
361
  for (const ssrMenu of ssrMenus) {
361
362
  const siteMenuOptions = ssrMenu.beanOptions
362
363
  .options as IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>;
364
+ validateSsrMenuSite(siteMenuOptions?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
363
365
  if (!checkSsrBinding(siteOnionName, siteMenuOptions.site)) continue;
364
366
  if (!checkSsrBinding(locale, siteMenuOptions.locale)) continue;
365
367
  const menusFrom =
@@ -1,7 +1,13 @@
1
- import { createBeanDecorator } from 'vona';
1
+ import { appResource, createBeanDecorator } from 'vona';
2
2
 
3
3
  import type { IDecoratorSsrMenuOptions } from '../types/ssrMenu.ts';
4
4
 
5
- export function SsrMenu<T extends IDecoratorSsrMenuOptions<any>>(options?: T): ClassDecorator {
6
- return createBeanDecorator('ssrMenu', options);
5
+ import { validateSsrMenuSite } from './ssrMenuSite.ts';
6
+
7
+ export function SsrMenu<T extends IDecoratorSsrMenuOptions<any>>(options: T): ClassDecorator {
8
+ validateSsrMenuSite(options?.site, 'SsrMenu');
9
+ return createBeanDecorator('ssrMenu', options, false, target => {
10
+ const beanOptions = appResource.getBean(target)!;
11
+ validateSsrMenuSite((beanOptions.options as T).site, beanOptions.beanFullName);
12
+ });
7
13
  }
@@ -9,6 +9,8 @@ import type {
9
9
  import type { IDecoratorSsrMenuGroupOptions, ISsrMenuGroupRecord } from '../types/ssrMenuGroup.ts';
10
10
  import type { IDecoratorSsrSiteOptions } from '../types/ssrSite.ts';
11
11
 
12
+ import { validateSsrMenuSite } from './ssrMenuSite.ts';
13
+
12
14
  export function checkSsrBinding<T>(expect: T, target: T | T[] | null | ''): boolean {
13
15
  if (target === undefined || target === null || target === '') return true;
14
16
  if (Array.isArray(target)) return target.some(item => item === expect);
@@ -20,6 +22,12 @@ export function resolveSsrMenuCatalog(
20
22
  ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
21
23
  ssrMenuGroups: IOnionSlice<ISsrMenuGroupRecord, keyof ISsrMenuGroupRecord, unknown>[] = [],
22
24
  ): ISsrMenuCatalog {
25
+ for (const ssrMenu of [...ssrMenus, ...ssrMenuGroups]) {
26
+ const options = ssrMenu.beanOptions.options as
27
+ | IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
28
+ | undefined;
29
+ validateSsrMenuSite(options?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
30
+ }
23
31
  const menus: ISsrMenuCatalog['menus'] = [];
24
32
  const groups: ISsrMenuCatalog['groups'] = [];
25
33
  for (const { ssrSiteName } of sites) {
@@ -87,6 +95,12 @@ export function resolveSsrMenuEligibility(
87
95
  ssrMenuName: string,
88
96
  ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
89
97
  ): ISsrMenuEligibility | undefined {
98
+ for (const ssrMenu of ssrMenus) {
99
+ const options = ssrMenu.beanOptions.options as
100
+ | IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
101
+ | undefined;
102
+ validateSsrMenuSite(options?.site, ssrMenu.beanOptions.beanFullName || ssrMenu.name);
103
+ }
90
104
  for (const ssrMenu of ssrMenus) {
91
105
  const options = ssrMenu.beanOptions.options as
92
106
  | IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
@@ -1,9 +1,15 @@
1
- import { createBeanDecorator } from 'vona';
1
+ import { appResource, createBeanDecorator } from 'vona';
2
2
 
3
3
  import type { IDecoratorSsrMenuGroupOptions } from '../types/ssrMenuGroup.ts';
4
4
 
5
+ import { validateSsrMenuSite } from './ssrMenuSite.ts';
6
+
5
7
  export function SsrMenuGroup<T extends IDecoratorSsrMenuGroupOptions<any>>(
6
- options?: T,
8
+ options: T,
7
9
  ): ClassDecorator {
8
- return createBeanDecorator('ssrMenuGroup', options);
10
+ validateSsrMenuSite(options?.site, 'SsrMenuGroup');
11
+ return createBeanDecorator('ssrMenuGroup', options, false, target => {
12
+ const beanOptions = appResource.getBean(target)!;
13
+ validateSsrMenuSite((beanOptions.options as T).site, beanOptions.beanFullName);
14
+ });
9
15
  }
@@ -0,0 +1,10 @@
1
+ export function validateSsrMenuSite(
2
+ site: unknown,
3
+ name: string,
4
+ ): asserts site is string | string[] {
5
+ const isSiteName = (value: unknown) => typeof value === 'string' && !!value.trim();
6
+ if (Array.isArray(site) ? [...site].every(isSiteName) : isSiteName(site)) return;
7
+ throw new TypeError(
8
+ `Should explicitly specify site for ${name}: use an SSR site name or an array of non-empty site names ([] means no sites)`,
9
+ );
10
+ }
@@ -96,7 +96,7 @@ export interface IDecoratorSsrMenuOptions<
96
96
  > extends IOnionOptionsEnable {
97
97
  item?: ISsrMenuItem<SsrSiteOptions['pages'], SsrSiteOptions['icons']>;
98
98
  items?: Record<string, ISsrMenuItem<SsrSiteOptions['pages'], SsrSiteOptions['icons']>>;
99
- site?: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
99
+ site: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
100
100
  locale?: keyof ILocaleRecord | (keyof ILocaleRecord)[];
101
101
  }
102
102
 
@@ -17,7 +17,7 @@ export interface IDecoratorSsrMenuGroupOptions<
17
17
  SsrSiteOptions extends IDecoratorSsrSiteOptions,
18
18
  > extends IOnionOptionsEnable {
19
19
  item?: ISsrMenuGroup<SsrSiteOptions['icons']>;
20
- site?: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
20
+ site: keyof ISsrSiteRecord | (keyof ISsrSiteRecord)[];
21
21
  locale?: keyof ILocaleRecord | (keyof ILocaleRecord)[];
22
22
  }
23
23
 
@@ -10,9 +10,10 @@ import {
10
10
  function createMenus() {
11
11
  return [
12
12
  {
13
- name: 'test:omitted',
13
+ name: 'test:shared',
14
14
  beanOptions: {
15
15
  options: {
16
+ site: ['test:admin', 'test:web'],
16
17
  item: { roles: [] },
17
18
  locale: ['en-us'],
18
19
  },
@@ -31,14 +32,16 @@ function createMenus() {
31
32
  },
32
33
  },
33
34
  {
34
- name: 'test:malformed',
35
- beanOptions: {},
35
+ name: 'test:unbound',
36
+ beanOptions: {
37
+ options: { site: [], item: { roles: [] } },
38
+ },
36
39
  },
37
40
  ] as any;
38
41
  }
39
42
 
40
43
  describe('ssrMenuEligibility.test.ts', () => {
41
- it('matches unset, scalar, or array bindings', () => {
44
+ it('preserves optional locale matching and scalar or array bindings', () => {
42
45
  assert.equal(checkSsrBinding('test:admin', undefined), true);
43
46
  assert.equal(checkSsrBinding('test:admin', null), true);
44
47
  assert.equal(checkSsrBinding('test:admin', ''), true);
@@ -64,8 +67,8 @@ describe('ssrMenuEligibility.test.ts', () => {
64
67
  assert.deepEqual(catalog.menus, [
65
68
  {
66
69
  ssrSiteName: 'test:admin',
67
- ssrMenuName: 'test:omitted',
68
- onionName: 'test:omitted',
70
+ ssrMenuName: 'test:shared',
71
+ onionName: 'test:shared',
69
72
  roles: [],
70
73
  },
71
74
  {
@@ -82,8 +85,8 @@ describe('ssrMenuEligibility.test.ts', () => {
82
85
  },
83
86
  {
84
87
  ssrSiteName: 'test:web',
85
- ssrMenuName: 'test:omitted',
86
- onionName: 'test:omitted',
88
+ ssrMenuName: 'test:shared',
89
+ onionName: 'test:shared',
87
90
  roles: [],
88
91
  },
89
92
  {
@@ -103,18 +106,75 @@ describe('ssrMenuEligibility.test.ts', () => {
103
106
 
104
107
  it('resolves final leaves independently of locale', () => {
105
108
  const menus = createMenus();
106
- assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:omitted', menus), {
109
+ assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:shared', menus), {
107
110
  ssrSiteName: 'test:admin',
108
- ssrMenuName: 'test:omitted',
111
+ ssrMenuName: 'test:shared',
109
112
  rolesDefined: true,
110
113
  });
111
- assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:omitted', menus), {
114
+ assert.deepEqual(resolveSsrMenuEligibility('test:web', 'test:shared', menus), {
112
115
  ssrSiteName: 'test:web',
113
- ssrMenuName: 'test:omitted',
116
+ ssrMenuName: 'test:shared',
114
117
  rolesDefined: true,
115
118
  });
116
119
  });
117
120
 
121
+ it('rejects invalid menu and group sites before catalog partitioning or eligibility lookup', () => {
122
+ const sites = [{ ssrSiteName: 'test:admin', title: 'Admin' }];
123
+ for (const site of [undefined, null, '', ' ', [''], ['test:admin', null]]) {
124
+ const invalid = [{ name: 'test:invalid', beanOptions: { options: { site } } }] as any;
125
+ assert.throws(() => resolveSsrMenuCatalog(sites, invalid), /site for test:invalid/);
126
+ assert.throws(() => resolveSsrMenuCatalog([], [], invalid), /site for test:invalid/);
127
+ assert.throws(
128
+ () =>
129
+ resolveSsrMenuEligibility('test:admin', 'test:shared', [...createMenus(), ...invalid]),
130
+ /site for test:invalid/,
131
+ );
132
+ }
133
+ const missing = [{ name: 'test:missingOptions', beanOptions: {} }] as any;
134
+ assert.throws(() => resolveSsrMenuCatalog(sites, missing), /site for test:missingOptions/);
135
+ assert.throws(
136
+ () => resolveSsrMenuEligibility('test:admin', 'test:missing', missing),
137
+ /site for test:missingOptions/,
138
+ );
139
+ });
140
+
141
+ it('partitions scalar and shared groups and accepts empty menu or group site arrays', () => {
142
+ const groups = [
143
+ { name: 'test:adminGroup', beanOptions: { options: { site: 'test:admin', item: {} } } },
144
+ {
145
+ name: 'test:sharedGroup',
146
+ beanOptions: { options: { site: ['test:admin', 'test:web'], item: {} } },
147
+ },
148
+ { name: 'test:unboundGroup', beanOptions: { options: { site: [], item: {} } } },
149
+ ] as any;
150
+ const catalog = resolveSsrMenuCatalog(
151
+ [
152
+ { ssrSiteName: 'test:admin', title: 'Admin' },
153
+ { ssrSiteName: 'test:web', title: 'Web' },
154
+ { ssrSiteName: 'test:future', title: 'Future' },
155
+ ],
156
+ createMenus(),
157
+ groups,
158
+ );
159
+ assert.deepEqual(
160
+ catalog.groups.map(group => [group.ssrSiteName, group.ssrMenuGroupName]),
161
+ [
162
+ ['test:admin', 'test:adminGroup'],
163
+ ['test:admin', 'test:sharedGroup'],
164
+ ['test:web', 'test:sharedGroup'],
165
+ ],
166
+ );
167
+ assert.equal(
168
+ catalog.menus.some(menu => menu.ssrMenuName === 'test:unbound'),
169
+ false,
170
+ );
171
+ assert.equal(
172
+ catalog.menus.some(menu => menu.ssrSiteName === 'test:future'),
173
+ false,
174
+ );
175
+ assert.equal(resolveSsrMenuEligibility('test:web', 'test:unbound', createMenus()), undefined);
176
+ });
177
+
118
178
  it('distinguishes dynamic/static leaves from public leaves and exact keyed names', () => {
119
179
  const menus = createMenus();
120
180
  assert.deepEqual(resolveSsrMenuEligibility('test:admin', 'test:keyed#static', menus), {
@@ -130,6 +190,6 @@ describe('ssrMenuEligibility.test.ts', () => {
130
190
  assert.equal(resolveSsrMenuEligibility('test:admin', 'test:keyed', menus), undefined);
131
191
  assert.equal(resolveSsrMenuEligibility('test:other', 'test:keyed#static', menus), undefined);
132
192
  assert.equal(resolveSsrMenuEligibility('test:admin', 'test:missing', menus), undefined);
133
- assert.equal(resolveSsrMenuEligibility('test:admin', 'test:malformed', menus), undefined);
193
+ assert.equal(resolveSsrMenuEligibility('test:admin', 'test:unbound', menus), undefined);
134
194
  });
135
195
  });
@@ -0,0 +1,90 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import type { IDecoratorSsrMenuOptions } from '../src/types/ssrMenu.ts';
5
+ import type { IDecoratorSsrMenuGroupOptions } from '../src/types/ssrMenuGroup.ts';
6
+ import type { IDecoratorSsrSiteOptions } from '../src/types/ssrSite.ts';
7
+
8
+ import { BeanSsrSiteBase } from '../src/lib/beanSsrSiteBase.ts';
9
+ import { SsrMenu } from '../src/lib/ssrMenu.ts';
10
+ import { SsrMenuGroup } from '../src/lib/ssrMenuGroup.ts';
11
+ import { validateSsrMenuSite } from '../src/lib/ssrMenuSite.ts';
12
+
13
+ function checkSiteTypes() {
14
+ // @ts-expect-error SSR menus must explicitly bind a site
15
+ const menu: IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions> = { item: {} };
16
+ // @ts-expect-error SSR menu groups must explicitly bind a site
17
+ const group: IDecoratorSsrMenuGroupOptions<IDecoratorSsrSiteOptions> = { item: {} };
18
+ // @ts-expect-error SSR menu decorator options are required
19
+ SsrMenu();
20
+ // @ts-expect-error SSR menu group decorator options are required
21
+ SsrMenuGroup();
22
+ return { menu, group };
23
+ }
24
+ void checkSiteTypes;
25
+
26
+ function prepareMenus(options: unknown, locale = 'en-us') {
27
+ const owner = {
28
+ $onionName: 'test:admin',
29
+ $scope: { ssr: { service: { ssr: { prepareMenuLink: (link: unknown) => link } } } },
30
+ };
31
+ return (BeanSsrSiteBase.prototype as any)._prepareMenusOrGroups.call(owner, locale, [
32
+ { name: 'test:menu', beanOptions: { options } },
33
+ ]);
34
+ }
35
+
36
+ describe('ssrMenuSite.test.ts', () => {
37
+ it('requires explicit nonblank sites in both decorators, including array entries', () => {
38
+ for (const site of [
39
+ undefined,
40
+ null,
41
+ '',
42
+ ' \t ',
43
+ 1,
44
+ false,
45
+ {},
46
+ [''],
47
+ ['test:admin', undefined],
48
+ Array.from({ length: 1 }),
49
+ ]) {
50
+ assert.throws(() => validateSsrMenuSite(site, 'test:menu'), /site for test:menu/);
51
+ assert.throws(() => SsrMenu({ site } as any), /site for SsrMenu/);
52
+ assert.throws(() => SsrMenuGroup({ site } as any), /site for SsrMenuGroup/);
53
+ }
54
+ assert.throws(() => SsrMenu(undefined as any), /explicitly specify site/);
55
+ assert.throws(() => SsrMenu({} as any), /explicitly specify site/);
56
+ assert.throws(() => SsrMenuGroup(undefined as any), /explicitly specify site/);
57
+ assert.throws(() => SsrMenuGroup({} as any), /explicitly specify site/);
58
+ });
59
+
60
+ it('accepts scalar sites, explicit sharing, and empty arrays without changing their values', () => {
61
+ for (const site of ['test:admin', ['test:admin', 'test:web'], []]) {
62
+ assert.doesNotThrow(() => validateSsrMenuSite(site, 'test:menu'));
63
+ assert.equal(typeof SsrMenu({ site } as any), 'function');
64
+ assert.equal(typeof SsrMenuGroup({ site } as any), 'function');
65
+ }
66
+ });
67
+
68
+ it('validates effective options before rendered menu site or locale filtering', () => {
69
+ for (const site of [undefined, null, '', ' ', ['']]) {
70
+ assert.throws(
71
+ () => prepareMenus({ site, locale: ['zh-cn'], item: {} }),
72
+ /site for test:menu/,
73
+ );
74
+ }
75
+ assert.throws(() => prepareMenus(undefined), /site for test:menu/);
76
+ });
77
+
78
+ it('preserves optional locale behavior and renders only explicitly bound menus or groups', () => {
79
+ const expected = [
80
+ { name: 'test:menu', title: undefined, description: undefined, link: undefined },
81
+ ];
82
+ assert.deepEqual(prepareMenus({ site: 'test:admin', item: {} }), expected);
83
+ assert.deepEqual(prepareMenus({ site: ['test:admin', 'test:web'], item: {} }), expected);
84
+ assert.deepEqual(prepareMenus({ site: [], item: {} }), []);
85
+ assert.deepEqual(prepareMenus({ site: 'test:web', item: {} }), []);
86
+ assert.deepEqual(prepareMenus({ site: 'test:admin', locale: ['zh-cn'], item: {} }), []);
87
+ assert.deepEqual(prepareMenus({ site: 'test:admin', locale: ['en-us'], item: {} }), expected);
88
+ assert.deepEqual(prepareMenus({ site: 'test:admin', locale: [], item: {} }), []);
89
+ });
90
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.57",
3
+ "version": "5.1.58",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-error",
3
- "version": "5.1.13",
3
+ "version": "5.1.14",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -50,6 +50,15 @@ export function onerror(app: VonaApplication, options?: OnerrorOptions) {
50
50
  return;
51
51
  }
52
52
 
53
+ // A disconnected request can become unreadable before the body parser observes its abort.
54
+ if (
55
+ this.req?.aborted &&
56
+ !this.writable &&
57
+ ['request.aborted', 'stream.not.readable'].includes(err.type)
58
+ ) {
59
+ return;
60
+ }
61
+
53
62
  // ignore all padding request stream
54
63
  if (this.req) {
55
64
  sendToWormhole(this.req);
@@ -0,0 +1,92 @@
1
+ import type { VonaApplication } from 'vona';
2
+
3
+ import assert from 'node:assert';
4
+ import { PassThrough } from 'node:stream';
5
+ import { describe, it } from 'node:test';
6
+
7
+ import { onerror } from '../src/lib/onerror.ts';
8
+
9
+ function createFixture(aborted: boolean, writable: boolean) {
10
+ const logged: Error[] = [];
11
+ const responses: unknown[] = [];
12
+ const app = { context: {} } as VonaApplication;
13
+ const options = {
14
+ all(_err: Error, ctx: any) {
15
+ ctx.body = 'error response';
16
+ },
17
+ log(err: Error) {
18
+ logged.push(err);
19
+ },
20
+ };
21
+ onerror(app, options);
22
+ const req = Object.assign(new PassThrough(), { aborted });
23
+ req.destroy();
24
+ const ctx = Object.assign(Object.create(app.context), {
25
+ req,
26
+ writable,
27
+ headerSent: false,
28
+ res: {
29
+ end(body: unknown) {
30
+ responses.push(body);
31
+ },
32
+ },
33
+ set() {},
34
+ accepts() {
35
+ return 'text';
36
+ },
37
+ });
38
+ return { ctx, logged, responses };
39
+ }
40
+
41
+ function createBodyError(type: string, status: number) {
42
+ return Object.assign(new Error(type), { type, status });
43
+ }
44
+
45
+ describe('onerror.test.ts', () => {
46
+ for (const [type, status] of [
47
+ ['stream.not.readable', 500],
48
+ ['request.aborted', 400],
49
+ ] as const) {
50
+ it(`ignores ${type} only for an aborted, unwritable request`, async () => {
51
+ const { ctx, logged, responses } = createFixture(true, false);
52
+ await ctx.onerror(createBodyError(type, status));
53
+ assert.deepEqual(logged, []);
54
+ assert.deepEqual(responses, []);
55
+ });
56
+
57
+ it(`keeps ${type} visible without confirmed request abortion`, async () => {
58
+ const { ctx, logged, responses } = createFixture(false, false);
59
+ const error = createBodyError(type, status);
60
+ await ctx.onerror(error);
61
+ assert.deepEqual(logged, [error]);
62
+ assert.equal(Reflect.get(error, 'headerSent'), true);
63
+ assert.deepEqual(responses, []);
64
+ });
65
+
66
+ it(`preserves ${type} handling while the response is writable`, async () => {
67
+ const { ctx, logged, responses } = createFixture(true, true);
68
+ const error = createBodyError(type, status);
69
+ await ctx.onerror(error);
70
+ assert.deepEqual(logged, [error]);
71
+ assert.equal(ctx.status, status);
72
+ assert.deepEqual(responses, ['error response']);
73
+ });
74
+ }
75
+
76
+ it('does not hide unrelated application errors on aborted requests', async () => {
77
+ const { ctx, logged, responses } = createFixture(true, false);
78
+ const error = new Error('application failure');
79
+ await ctx.onerror(error);
80
+ assert.deepEqual(logged, [error]);
81
+ assert.deepEqual(responses, []);
82
+ });
83
+
84
+ it('preserves body parsing errors on a writable request', async () => {
85
+ const { ctx, logged, responses } = createFixture(false, true);
86
+ const error = createBodyError('entity.parse.failed', 400);
87
+ await ctx.onerror(error);
88
+ assert.deepEqual(logged, [error]);
89
+ assert.equal(ctx.status, 400);
90
+ assert.deepEqual(responses, ['error response']);
91
+ });
92
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-vona",
3
- "version": "5.1.92",
3
+ "version": "5.1.93",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",