@jskit-ai/kernel 0.1.32 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -37,6 +37,7 @@
37
37
  "./shared/support/crudLookup": "./shared/support/crudLookup.js",
38
38
  "./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
39
39
  "./shared/support/listenerSet": "./shared/support/listenerSet.js",
40
+ "./shared/support/shellLayoutTargets": "./shared/support/shellLayoutTargets.js",
40
41
  "./shared/support/providerLogger": "./shared/support/providerLogger.js",
41
42
  "./shared/support/returnToPath": "./shared/support/returnToPath.js",
42
43
  "./shared/support/visibility": "./shared/support/visibility.js",
@@ -236,7 +236,7 @@ test("registerRoutes attaches visibilityContext from route visibility resolvers"
236
236
  }
237
237
 
238
238
  return {
239
- userOwnerId: context?.actor?.id,
239
+ userId: context?.actor?.id,
240
240
  requiresActorScope: true
241
241
  };
242
242
  }
@@ -281,7 +281,7 @@ test("registerRoutes attaches visibilityContext from route visibility resolvers"
281
281
  scopeKind: null,
282
282
  requiresActorScope: true,
283
283
  scopeOwnerId: null,
284
- userOwnerId: 23
284
+ userId: "23"
285
285
  });
286
286
  assert.deepEqual(observed[0].context.requestMeta.visibilityContext, observed[0].context.visibilityContext);
287
287
  assert.equal(observed[0].context.requestMeta.routeVisibility, "user");
@@ -309,7 +309,7 @@ test("registerRoutes keeps actor scope requirement for core user visibility with
309
309
  }
310
310
 
311
311
  return {
312
- userOwnerId: context?.actor?.id
312
+ userId: context?.actor?.id
313
313
  };
314
314
  }
315
315
  }));
@@ -353,7 +353,7 @@ test("registerRoutes keeps actor scope requirement for core user visibility with
353
353
  scopeKind: null,
354
354
  requiresActorScope: true,
355
355
  scopeOwnerId: null,
356
- userOwnerId: 23
356
+ userId: "23"
357
357
  });
358
358
  assert.equal(observed[0].context.requestMeta.routeVisibility, "user");
359
359
  });
@@ -399,7 +399,7 @@ test("registerRoutes does not infer actor scope from non-core route visibility t
399
399
  scopeKind: null,
400
400
  requiresActorScope: false,
401
401
  scopeOwnerId: null,
402
- userOwnerId: null
402
+ userId: null
403
403
  });
404
404
  assert.equal(observed[0].context.requestMeta.routeVisibility, "workspace_user");
405
405
  });
@@ -43,10 +43,10 @@ function resolveVisibilityScope(visibilityContext = {}, runtimeContext = {}) {
43
43
  const visibility = normalizeText(visibilityContext.visibility).toLowerCase();
44
44
  const scopeKind = normalizeText(visibilityContext.scopeKind || visibility).toLowerCase();
45
45
  const scopeOwnerId = normalizeOpaqueId(visibilityContext.scopeOwnerId);
46
- const userOwnerId = normalizeOpaqueId(visibilityContext.userOwnerId);
46
+ const userId = normalizeOpaqueId(visibilityContext.userId);
47
47
  const requiresActorScope = visibilityContext.requiresActorScope === true;
48
48
 
49
- if (requiresActorScope && userOwnerId == null) {
49
+ if (requiresActorScope && userId == null) {
50
50
  return null;
51
51
  }
52
52
 
@@ -57,7 +57,7 @@ function resolveVisibilityScope(visibilityContext = {}, runtimeContext = {}) {
57
57
  };
58
58
  if (requiresActorScope) {
59
59
  scope.scopeId = scopeOwnerId;
60
- scope.userId = userOwnerId;
60
+ scope.userId = userId;
61
61
  }
62
62
  return scope;
63
63
  }
@@ -72,10 +72,10 @@ function resolveVisibilityScope(visibilityContext = {}, runtimeContext = {}) {
72
72
  };
73
73
  }
74
74
 
75
- if (scopeKind === "user" && userOwnerId != null) {
75
+ if (scopeKind === "user" && userId != null) {
76
76
  return {
77
77
  kind: "user",
78
- id: userOwnerId
78
+ id: userId
79
79
  };
80
80
  }
81
81
 
@@ -33,9 +33,9 @@ test("entity change publisher emits normalized event payload", async () => {
33
33
  });
34
34
 
35
35
  assert.equal(payload?.operation, "created");
36
- assert.equal(payload?.entityId, 5);
37
- assert.deepEqual(payload?.scope, { kind: "scope", id: 23 });
38
- assert.equal(payload?.actorId, 17);
36
+ assert.equal(payload?.entityId, "5");
37
+ assert.deepEqual(payload?.scope, { kind: "scope", id: "23" });
38
+ assert.equal(payload?.actorId, "17");
39
39
  assert.equal(payload?.commandId, "cmd-1");
40
40
  assert.equal(payload?.sourceClientId, "client-a");
41
41
  assert.equal(payload?.meta?.service?.token, "crud.customers");
@@ -111,7 +111,7 @@ test("entity change publisher infers scoped owner from service context when visi
111
111
  }
112
112
  );
113
113
 
114
- assert.deepEqual(payload?.scope, { kind: "workspace", id: 23 });
114
+ assert.deepEqual(payload?.scope, { kind: "workspace", id: "23" });
115
115
  assert.equal(published.length, 1);
116
116
  });
117
117
 
@@ -133,7 +133,7 @@ test("entity change publisher supports opaque actor and scope identifiers", asyn
133
133
  visibilityContext: {
134
134
  scopeKind: "workspace_user",
135
135
  scopeOwnerId: "workspace_23",
136
- userOwnerId: "user_17",
136
+ userId: "user_17",
137
137
  requiresActorScope: true
138
138
  }
139
139
  }
@@ -24,7 +24,7 @@ test("requireAuth accepts actor context", () => {
24
24
  }
25
25
  });
26
26
 
27
- assert.equal(actor.id, 7);
27
+ assert.equal(actor.id, "7");
28
28
  });
29
29
 
30
30
  test("requireAuth accepts non-numeric actor ids", () => {
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ let freshImportCounter = 0;
5
+
6
+ function nextFreshImportToken() {
7
+ freshImportCounter += 1;
8
+ return String(freshImportCounter);
9
+ }
10
+
11
+ async function importFreshModuleFromAbsolutePath(absolutePath) {
12
+ const normalizedPath = String(absolutePath || "").trim();
13
+ if (!normalizedPath || !path.isAbsolute(normalizedPath)) {
14
+ throw new Error("importFreshModuleFromAbsolutePath requires an absolute path.");
15
+ }
16
+
17
+ const resolvedPath = path.resolve(normalizedPath);
18
+ const moduleUrl = pathToFileURL(resolvedPath);
19
+ moduleUrl.searchParams.set("jskit_fresh", nextFreshImportToken());
20
+ return import(moduleUrl.href);
21
+ }
22
+
23
+ export { importFreshModuleFromAbsolutePath };
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
7
+
8
+ test("importFreshModuleFromAbsolutePath requires an absolute path", async () => {
9
+ await assert.rejects(
10
+ () => importFreshModuleFromAbsolutePath("relative/module.js"),
11
+ /requires an absolute path/
12
+ );
13
+ });
14
+
15
+ test("importFreshModuleFromAbsolutePath re-evaluates a module on each call", async () => {
16
+ const tempRoot = await mkdtemp(path.join(tmpdir(), "jskit-import-fresh-module-"));
17
+ const modulePath = path.join(tempRoot, "counter.mjs");
18
+ const counterKey = "__jskitKernelImportFreshCounter";
19
+ delete globalThis[counterKey];
20
+
21
+ await writeFile(
22
+ modulePath,
23
+ `globalThis.${counterKey} = Number(globalThis.${counterKey} || 0) + 1;
24
+ export const loadCount = globalThis.${counterKey};
25
+ `,
26
+ "utf8"
27
+ );
28
+
29
+ const first = await importFreshModuleFromAbsolutePath(modulePath);
30
+ const second = await importFreshModuleFromAbsolutePath(modulePath);
31
+
32
+ assert.equal(first.loadCount, 1);
33
+ assert.equal(second.loadCount, 2);
34
+
35
+ delete globalThis[counterKey];
36
+ });
@@ -1,6 +1,7 @@
1
1
  export { symlinkSafeRequire } from "./symlinkSafeRequire.js";
2
2
  export { resolveAppConfig } from "./appConfig.js";
3
3
  export { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
4
+ export { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
4
5
  export { resolveRequiredAppRoot, toPosixPath } from "./path.js";
5
6
  export {
6
7
  DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
@@ -16,8 +16,9 @@ import {
16
16
  } from "../../shared/support/shellLayoutTargets.js";
17
17
  import { resolveShellOutletPlacementTargetFromApp } from "./shellOutlets.js";
18
18
  import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
19
+ import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
19
20
 
20
- const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "users.web.shell.surface-aware-menu-link-item";
21
+ const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
21
22
  const DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
22
23
  const PAGE_ROOT_PREFIX = "src/pages/";
23
24
  const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
@@ -64,17 +65,17 @@ function resolvePagesRelativeAppPath(
64
65
  if (isAbsolutePathInput(normalizedValue)) {
65
66
  throw new Error(`${context} ${label} must be relative to src/pages/: ${normalizedValue}.`);
66
67
  }
67
- if (
68
- normalizedValue === "src/pages" ||
69
- normalizedValue.startsWith(PAGE_ROOT_PREFIX)
70
- ) {
68
+ if (normalizedValue === "src/pages" || normalizedValue === PAGE_ROOT_PREFIX) {
71
69
  throw new Error(
72
- `${context} ${label} must be relative to src/pages/, without the src/pages/ prefix: ${normalizedValue}.`
70
+ `${context} ${label} must include a path under src/pages/: ${normalizedValue}.`
73
71
  );
74
72
  }
73
+ if (normalizedValue.startsWith(PAGE_ROOT_PREFIX)) {
74
+ return normalizedValue;
75
+ }
75
76
  if (normalizedValue.startsWith("src/")) {
76
77
  throw new Error(
77
- `${context} ${label} must be relative to src/pages/, without a leading src/ segment: ${normalizedValue}.`
78
+ `${context} ${label} must be relative to src/pages/ or start with src/pages/: ${normalizedValue}.`
78
79
  );
79
80
  }
80
81
 
@@ -174,38 +175,46 @@ function humanizePageSegment(value = "", fallback = "Page") {
174
175
 
175
176
  async function loadPublicConfig(appRoot = "", { context = "page target" } = {}) {
176
177
  const resolvedAppRoot = resolveRequiredAppRoot(appRoot, { context });
177
- const configPath = path.join(resolvedAppRoot, "config", "public.js");
178
-
179
- try {
180
- await readFile(configPath, "utf8");
181
- } catch {
182
- throw new Error(`${context} requires app config at config/public.js.`);
183
- }
184
-
185
- let moduleNamespace = null;
186
178
  try {
187
- moduleNamespace = await import(pathToFileURL(configPath).href);
179
+ const config = normalizeObject(
180
+ await loadAppConfigFromModuleUrl({
181
+ moduleUrl: pathToFileURL(path.join(resolvedAppRoot, "config", "public.js")).href
182
+ })
183
+ );
184
+ if (Object.keys(config).length < 1) {
185
+ throw new Error("requires exported config in config/public.js.");
186
+ }
187
+ return config;
188
188
  } catch (error) {
189
189
  throw new Error(
190
190
  `${context} could not load config/public.js: ${String(error?.message || error || "unknown error")}`
191
191
  );
192
192
  }
193
+ }
193
194
 
194
- const config = normalizeObject(
195
- moduleNamespace?.config ||
196
- moduleNamespace?.default?.config ||
197
- moduleNamespace?.default
198
- );
199
- if (Object.keys(config).length < 1) {
200
- throw new Error(`${context} requires exported config in config/public.js.`);
195
+ function normalizeSurfaceAccessPolicyId(value = "") {
196
+ return normalizeText(value).toLowerCase();
197
+ }
198
+
199
+ function resolveSurfaceRequiresAuth(surfaceDefinition = {}, surfaceAccessPolicies = {}) {
200
+ const normalizedDefinition = normalizeObject(surfaceDefinition);
201
+ const normalizedPolicies = normalizeObject(surfaceAccessPolicies);
202
+ const accessPolicyId = normalizeSurfaceAccessPolicyId(normalizedDefinition.accessPolicyId);
203
+ const configuredPolicy = accessPolicyId
204
+ ? normalizeObject(normalizedPolicies[accessPolicyId])
205
+ : {};
206
+
207
+ if (Object.hasOwn(configuredPolicy, "requireAuth")) {
208
+ return configuredPolicy.requireAuth === true;
201
209
  }
202
210
 
203
- return config;
211
+ return normalizedDefinition.requiresAuth === true;
204
212
  }
205
213
 
206
214
  async function listSurfacePageRoots(appRoot = "", { context = "page target" } = {}) {
207
215
  const config = await loadPublicConfig(appRoot, { context });
208
216
  const surfaceDefinitions = normalizeObject(config.surfaceDefinitions);
217
+ const surfaceAccessPolicies = normalizeObject(config.surfaceAccessPolicies);
209
218
 
210
219
  return Object.freeze(
211
220
  Object.entries(surfaceDefinitions)
@@ -218,7 +227,8 @@ async function listSurfacePageRoots(appRoot = "", { context = "page target" } =
218
227
 
219
228
  return Object.freeze({
220
229
  id: surfaceId,
221
- pagesRoot: normalizeSurfacePagesRoot(definition.pagesRoot)
230
+ pagesRoot: normalizeSurfacePagesRoot(definition.pagesRoot),
231
+ requiresAuth: resolveSurfaceRequiresAuth(definition, surfaceAccessPolicies)
222
232
  });
223
233
  })
224
234
  .filter(Boolean)
@@ -239,6 +249,7 @@ function deriveSurfaceMatchesFromPageFile(relativePath = "", surfacePageRoots =
239
249
  return Object.freeze({
240
250
  surfaceId: normalizeSurfaceId(surface?.id),
241
251
  pagesRoot,
252
+ requiresAuth: surface?.requiresAuth === true,
242
253
  surfaceRelativeFilePath: pagePathWithinPagesRoot
243
254
  });
244
255
  }
@@ -251,6 +262,7 @@ function deriveSurfaceMatchesFromPageFile(relativePath = "", surfacePageRoots =
251
262
  return Object.freeze({
252
263
  surfaceId: normalizeSurfaceId(surface?.id),
253
264
  pagesRoot,
265
+ requiresAuth: surface?.requiresAuth === true,
254
266
  surfaceRelativeFilePath: pagePathWithinPagesRoot.slice(requiredPrefix.length)
255
267
  });
256
268
  })
@@ -433,9 +445,7 @@ function resolveSubpagesHostTargetFromPageSource(source = "") {
433
445
  }
434
446
 
435
447
  return Object.freeze({
436
- id: target.id,
437
- host: target.host,
438
- position: target.position
448
+ id: target.id
439
449
  });
440
450
  }
441
451
 
@@ -468,6 +478,7 @@ async function resolvePageTargetDetails({
468
478
  }),
469
479
  surfaceId: surfaceMatch.surfaceId,
470
480
  surfacePagesRoot: surfaceMatch.pagesRoot,
481
+ surfaceRequiresAuth: surfaceMatch.requiresAuth === true,
471
482
  surfaceRelativeFilePath: surfaceMatch.surfaceRelativeFilePath,
472
483
  ...routeInfo
473
484
  });
@@ -530,12 +541,7 @@ async function resolveNearestParentSubpagesHost({
530
541
  }
531
542
 
532
543
  function normalizePlacementTargetId(target = {}) {
533
- const host = normalizeText(target?.host);
534
- const position = normalizeText(target?.position);
535
- if (!host || !position) {
536
- return "";
537
- }
538
- return normalizeShellOutletTargetId(`${host}:${position}`);
544
+ return normalizeShellOutletTargetId(target?.id || target?.target || target);
539
545
  }
540
546
 
541
547
  function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
@@ -616,6 +622,11 @@ function resolveInferredPageLinkComponentToken({
616
622
  return normalizedExplicitToken;
617
623
  }
618
624
 
625
+ const normalizedPlacementTargetDefaultToken = normalizeText(placementTarget?.defaultLinkComponentToken);
626
+ if (normalizedPlacementTargetDefaultToken) {
627
+ return normalizedPlacementTargetDefaultToken;
628
+ }
629
+
619
630
  const parentTargetId = normalizePlacementTargetId(parentHost);
620
631
  const placementTargetId = normalizePlacementTargetId(placementTarget);
621
632
  if (parentTargetId && parentTargetId === placementTargetId) {
@@ -625,6 +636,14 @@ function resolveInferredPageLinkComponentToken({
625
636
  return normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN;
626
637
  }
627
638
 
639
+ function renderPageLinkWhenLine(pageTarget = {}) {
640
+ if (pageTarget?.surfaceRequiresAuth !== true) {
641
+ return "";
642
+ }
643
+
644
+ return " when: ({ auth }) => Boolean(auth?.authenticated)\n";
645
+ }
646
+
628
647
  async function resolvePageLinkTargetDetails({
629
648
  appRoot,
630
649
  targetFile = "",
@@ -663,6 +682,7 @@ async function resolvePageLinkTargetDetails({
663
682
  defaultComponentToken,
664
683
  subpageComponentToken
665
684
  }),
685
+ whenLine: renderPageLinkWhenLine(resolvedPageTarget),
666
686
  linkTo: resolveInferredPageLinkTo({
667
687
  explicitLinkTo: linkTo,
668
688
  pageTarget: resolvedPageTarget,
@@ -36,8 +36,12 @@ async function writeShellLayout(appRoot, source = "") {
36
36
  source ||
37
37
  `<template>
38
38
  <div>
39
- <ShellOutlet host="shell-layout" position="top-right" />
40
- <ShellOutlet host="shell-layout" position="primary-menu" default />
39
+ <ShellOutlet target="shell-layout:top-right" />
40
+ <ShellOutlet
41
+ target="shell-layout:primary-menu"
42
+ default
43
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
44
+ />
41
45
  </div>
42
46
  </template>
43
47
  `
@@ -128,6 +132,41 @@ test("resolvePageTargetDetails chooses the most specific matching surface pagesR
128
132
  });
129
133
  });
130
134
 
135
+ test("resolvePageTargetDetails derives surface auth requirement from surface access policy", async () => {
136
+ await withTempApp(async (appRoot) => {
137
+ await writeConfig(
138
+ appRoot,
139
+ `export const config = {
140
+ surfaceAccessPolicies: {
141
+ public: {},
142
+ authenticated: {
143
+ requireAuth: true
144
+ }
145
+ },
146
+ surfaceDefinitions: {
147
+ home: { id: "home", pagesRoot: "home", enabled: true, accessPolicyId: "public" },
148
+ app: { id: "app", pagesRoot: "app", enabled: true, accessPolicyId: "authenticated" }
149
+ }
150
+ };
151
+ `
152
+ );
153
+
154
+ const publicPageTarget = await resolvePageTargetDetails({
155
+ appRoot,
156
+ targetFile: "home/index.vue",
157
+ context: "page target"
158
+ });
159
+ const authenticatedPageTarget = await resolvePageTargetDetails({
160
+ appRoot,
161
+ targetFile: "app/index.vue",
162
+ context: "page target"
163
+ });
164
+
165
+ assert.equal(publicPageTarget.surfaceRequiresAuth, false);
166
+ assert.equal(authenticatedPageTarget.surfaceRequiresAuth, true);
167
+ });
168
+ });
169
+
131
170
  test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot definitions", async () => {
132
171
  await withTempApp(async (appRoot) => {
133
172
  await writeConfig(
@@ -153,7 +192,7 @@ test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot defi
153
192
  });
154
193
  });
155
194
 
156
- test("resolvePageTargetDetails rejects target files with a src/pages prefix", async () => {
195
+ test("resolvePageTargetDetails accepts target files with a src/pages prefix", async () => {
157
196
  await withTempApp(async (appRoot) => {
158
197
  await writeConfig(
159
198
  appRoot,
@@ -165,26 +204,25 @@ test("resolvePageTargetDetails rejects target files with a src/pages prefix", as
165
204
  `
166
205
  );
167
206
 
168
- await assert.rejects(
169
- () =>
170
- resolvePageTargetDetails({
171
- appRoot,
172
- targetFile: "src/pages/admin/reports/index.vue",
173
- context: "page target"
174
- }),
175
- /must be relative to src\/pages\/, without the src\/pages\/ prefix/
176
- );
207
+ const details = await resolvePageTargetDetails({
208
+ appRoot,
209
+ targetFile: "src/pages/admin/reports/index.vue",
210
+ context: "page target"
211
+ });
212
+
213
+ assert.equal(details.targetFilePath.relativePath, "src/pages/admin/reports/index.vue");
214
+ assert.equal(details.surfaceId, "admin");
215
+ assert.equal(details.routeUrlSuffix, "/reports");
177
216
  });
178
217
  });
179
218
 
180
- test("normalizePagesRelativeTargetRoot rejects route roots with a src/pages prefix", () => {
181
- assert.throws(
182
- () =>
183
- normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
184
- context: "crud-ui-generator",
185
- label: 'option "target-root"'
186
- }),
187
- /must be relative to src\/pages\/, without the src\/pages\/ prefix/
219
+ test("normalizePagesRelativeTargetRoot accepts route roots with a src/pages prefix", () => {
220
+ assert.equal(
221
+ normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
222
+ context: "crud-ui-generator",
223
+ label: 'option "target-root"'
224
+ }),
225
+ "src/pages/admin/customers"
188
226
  );
189
227
  });
190
228
 
@@ -208,10 +246,77 @@ test("resolvePageLinkTargetDetails falls back to the app default placement targe
208
246
  });
209
247
 
210
248
  assert.equal(details.pageTarget.surfaceId, "admin");
211
- assert.equal(details.placementTarget.host, "shell-layout");
212
- assert.equal(details.placementTarget.position, "primary-menu");
213
- assert.equal(details.componentToken, "users.web.shell.surface-aware-menu-link-item");
249
+ assert.equal(details.placementTarget.id, "shell-layout:primary-menu");
250
+ assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
214
251
  assert.equal(details.linkTo, "");
252
+ assert.equal(details.whenLine, "");
253
+ });
254
+ });
255
+
256
+ test("resolvePageLinkTargetDetails emits an auth guard when the surface policy requires auth", async () => {
257
+ await withTempApp(async (appRoot) => {
258
+ await writeConfig(
259
+ appRoot,
260
+ `export const config = {
261
+ surfaceAccessPolicies: {
262
+ authenticated: {
263
+ requireAuth: true
264
+ }
265
+ },
266
+ surfaceDefinitions: {
267
+ app: { id: "app", pagesRoot: "app", enabled: true, accessPolicyId: "authenticated" }
268
+ }
269
+ };
270
+ `
271
+ );
272
+ await writeShellLayout(appRoot);
273
+
274
+ const details = await resolvePageLinkTargetDetails({
275
+ appRoot,
276
+ targetFile: "app/reports/index.vue",
277
+ context: "page target"
278
+ });
279
+
280
+ assert.equal(details.whenLine, " when: ({ auth }) => Boolean(auth?.authenticated)\n");
281
+ });
282
+ });
283
+
284
+ test("resolvePageLinkTargetDetails prefers an outlet-declared default link token over subpage heuristics", async () => {
285
+ await withTempApp(async (appRoot) => {
286
+ await writeConfig(
287
+ appRoot,
288
+ `export const config = {
289
+ surfaceDefinitions: {
290
+ home: { id: "home", pagesRoot: "home", enabled: true }
291
+ }
292
+ };
293
+ `
294
+ );
295
+ await writeFileInApp(
296
+ appRoot,
297
+ "src/pages/home/settings.vue",
298
+ `<template>
299
+ <section>
300
+ <ShellOutlet
301
+ target="home-settings:primary-menu"
302
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
303
+ />
304
+ <RouterView />
305
+ </section>
306
+ </template>
307
+ `
308
+ );
309
+
310
+ const details = await resolvePageLinkTargetDetails({
311
+ appRoot,
312
+ targetFile: "home/settings/pollen-types/index.vue",
313
+ context: "page target"
314
+ });
315
+
316
+ assert.equal(details.parentHost?.id, "home-settings:primary-menu");
317
+ assert.equal(details.placementTarget.id, "home-settings:primary-menu");
318
+ assert.equal(details.componentToken, "local.main.ui.surface-aware-menu-link-item");
319
+ assert.equal(details.linkTo, "./pollen-types");
215
320
  });
216
321
  });
217
322
 
@@ -233,7 +338,7 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
233
338
  `<template>
234
339
  <SectionContainerShell>
235
340
  <template #tabs>
236
- <ShellOutlet host="contact-view" position="sub-pages" />
341
+ <ShellOutlet target="contact-view:sub-pages" />
237
342
  </template>
238
343
  <RouterView />
239
344
  </SectionContainerShell>
@@ -248,8 +353,7 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
248
353
  });
249
354
 
250
355
  assert.equal(details.parentHost?.id, "contact-view:sub-pages");
251
- assert.equal(details.placementTarget.host, "contact-view");
252
- assert.equal(details.placementTarget.position, "sub-pages");
356
+ assert.equal(details.placementTarget.id, "contact-view:sub-pages");
253
357
  assert.equal(details.componentToken, "local.main.ui.tab-link-item");
254
358
  assert.equal(details.linkTo, "./notes");
255
359
  });
@@ -277,8 +381,7 @@ test("resolvePageLinkTargetDetails honors explicit placement and link overrides"
277
381
  context: "page target"
278
382
  });
279
383
 
280
- assert.equal(details.placementTarget.host, "shell-layout");
281
- assert.equal(details.placementTarget.position, "top-right");
384
+ assert.equal(details.placementTarget.id, "shell-layout:top-right");
282
385
  assert.equal(details.componentToken, "custom.link-item");
283
386
  assert.equal(details.linkTo, "./assistant-notes");
284
387
  });
@@ -302,7 +405,7 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
302
405
  `<template>
303
406
  <SectionContainerShell>
304
407
  <template #tabs>
305
- <ShellOutlet host="customer-view" position="sub-pages" />
408
+ <ShellOutlet target="customer-view:sub-pages" />
306
409
  </template>
307
410
  <RouterView />
308
411
  </SectionContainerShell>
@@ -318,8 +421,7 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
318
421
 
319
422
  assert.equal(details.parentHost?.id, "customer-view:sub-pages");
320
423
  assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
321
- assert.equal(details.placementTarget.host, "customer-view");
322
- assert.equal(details.placementTarget.position, "sub-pages");
424
+ assert.equal(details.placementTarget.id, "customer-view:sub-pages");
323
425
  assert.equal(details.componentToken, "local.main.ui.tab-link-item");
324
426
  assert.equal(details.linkTo, "./pets");
325
427
  });