@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 +2 -1
- package/server/http/lib/kernel.test.js +5 -5
- package/server/runtime/entityChangeEvents.js +5 -5
- package/server/runtime/entityChangeEvents.test.js +5 -5
- package/server/runtime/serviceAuthorization.test.js +1 -1
- package/server/support/importFreshModuleFromAbsolutePath.js +23 -0
- package/server/support/importFreshModuleFromAbsolutePath.test.js +36 -0
- package/server/support/index.js +1 -0
- package/server/support/pageTargets.js +55 -35
- package/server/support/pageTargets.test.js +133 -31
- package/server/support/shellOutlets.js +62 -30
- package/server/support/shellOutlets.test.js +86 -27
- package/shared/support/normalize.js +31 -1
- package/shared/support/normalize.test.js +21 -2
- package/shared/support/shellLayoutTargets.js +68 -25
- package/shared/support/shellLayoutTargets.test.js +27 -9
- package/shared/support/visibility.js +1 -1
- package/shared/support/visibility.test.js +5 -5
- package/shared/validators/cursorPaginationQueryValidator.js +3 -4
- package/shared/validators/cursorPaginationQueryValidator.test.js +2 -3
- package/shared/validators/index.js +11 -1
- package/shared/validators/recordIdParamsValidator.js +47 -9
- package/shared/validators/recordIdParamsValidator.test.js +7 -4
- package/README.md +0 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/kernel",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
46
|
+
const userId = normalizeOpaqueId(visibilityContext.userId);
|
|
47
47
|
const requiresActorScope = visibilityContext.requiresActorScope === true;
|
|
48
48
|
|
|
49
|
-
if (requiresActorScope &&
|
|
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 =
|
|
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" &&
|
|
75
|
+
if (scopeKind === "user" && userId != null) {
|
|
76
76
|
return {
|
|
77
77
|
kind: "user",
|
|
78
|
-
id:
|
|
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
|
-
|
|
136
|
+
userId: "user_17",
|
|
137
137
|
requiresActorScope: true
|
|
138
138
|
}
|
|
139
139
|
}
|
|
@@ -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
|
+
});
|
package/server/support/index.js
CHANGED
|
@@ -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 = "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
40
|
-
<ShellOutlet
|
|
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
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
181
|
-
assert.
|
|
182
|
-
(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
212
|
-
assert.equal(details.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
});
|