@jskit-ai/users-web 0.1.4

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 (97) hide show
  1. package/package.descriptor.mjs +507 -0
  2. package/package.json +31 -0
  3. package/src/client/components/ConsoleSettingsClientElement.vue +24 -0
  4. package/src/client/components/MembersAdminClientElement.vue +404 -0
  5. package/src/client/components/ProfileClientElement.vue +242 -0
  6. package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +39 -0
  7. package/src/client/components/UsersShellMenuLinkItem.vue +140 -0
  8. package/src/client/components/UsersSurfaceAwareMenuLinkItem.vue +87 -0
  9. package/src/client/components/UsersWorkspaceMembersMenuItem.vue +36 -0
  10. package/src/client/components/UsersWorkspacePermissionMenuItem.vue +90 -0
  11. package/src/client/components/UsersWorkspaceSelector.vue +237 -0
  12. package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +39 -0
  13. package/src/client/components/UsersWorkspaceToolsWidget.vue +23 -0
  14. package/src/client/components/WorkspaceMembersClientElement.vue +663 -0
  15. package/src/client/components/WorkspaceSettingsClientElement.vue +230 -0
  16. package/src/client/components/WorkspacesClientElement.vue +514 -0
  17. package/src/client/composables/accountSettingsAvatarUploadRuntime.js +241 -0
  18. package/src/client/composables/accountSettingsInvitesRuntime.js +88 -0
  19. package/src/client/composables/accountSettingsRuntimeConstants.js +77 -0
  20. package/src/client/composables/accountSettingsRuntimeHelpers.js +75 -0
  21. package/src/client/composables/errorMessageHelpers.js +66 -0
  22. package/src/client/composables/internal/useOperationScope.js +144 -0
  23. package/src/client/composables/modelStateHelpers.js +49 -0
  24. package/src/client/composables/operationUiHelpers.js +121 -0
  25. package/src/client/composables/operationValidationHelpers.js +52 -0
  26. package/src/client/composables/refValueHelpers.js +19 -0
  27. package/src/client/composables/scopeHelpers.js +145 -0
  28. package/src/client/composables/useAccess.js +109 -0
  29. package/src/client/composables/useAccountSettingsRuntime.js +533 -0
  30. package/src/client/composables/useAddEdit.js +135 -0
  31. package/src/client/composables/useAddEditCore.js +137 -0
  32. package/src/client/composables/useBootstrapQuery.js +52 -0
  33. package/src/client/composables/useCommand.js +112 -0
  34. package/src/client/composables/useCommandCore.js +130 -0
  35. package/src/client/composables/useEndpointResource.js +104 -0
  36. package/src/client/composables/useFieldErrorBag.js +61 -0
  37. package/src/client/composables/useList.js +85 -0
  38. package/src/client/composables/useListCore.js +65 -0
  39. package/src/client/composables/usePagedCollection.js +125 -0
  40. package/src/client/composables/usePaths.js +108 -0
  41. package/src/client/composables/useRealtimeQueryInvalidation.js +105 -0
  42. package/src/client/composables/useScopeRuntime.js +107 -0
  43. package/src/client/composables/useSurfaceRouteContext.js +31 -0
  44. package/src/client/composables/useUiFeedback.js +96 -0
  45. package/src/client/composables/useView.js +89 -0
  46. package/src/client/composables/useViewCore.js +104 -0
  47. package/src/client/composables/useWorkspaceRouteContext.js +28 -0
  48. package/src/client/composables/useWorkspaceSurfaceId.js +43 -0
  49. package/src/client/index.js +7 -0
  50. package/src/client/lib/bootstrap.js +95 -0
  51. package/src/client/lib/httpClient.js +67 -0
  52. package/src/client/lib/menuIcons.js +192 -0
  53. package/src/client/lib/permissions.js +34 -0
  54. package/src/client/lib/profileSurfaceMenuLinks.js +142 -0
  55. package/src/client/lib/surfaceAccessPolicy.js +350 -0
  56. package/src/client/lib/theme.js +99 -0
  57. package/src/client/lib/workspaceLinkResolver.js +207 -0
  58. package/src/client/lib/workspaceSurfaceContext.js +82 -0
  59. package/src/client/lib/workspaceSurfacePaths.js +163 -0
  60. package/src/client/providers/UsersWebClientProvider.js +85 -0
  61. package/src/client/runtime/bootstrapPlacementRouteGuards.js +371 -0
  62. package/src/client/runtime/bootstrapPlacementRuntime.js +413 -0
  63. package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +32 -0
  64. package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +157 -0
  65. package/src/client/support/contractGuards.js +34 -0
  66. package/src/client/support/realtimeWorkspace.js +12 -0
  67. package/src/client/support/runtimeNormalization.js +27 -0
  68. package/src/client/support/workspaceQueryKeys.js +15 -0
  69. package/templates/packages/main/src/client/components/AccountPendingInvitesCue.vue +162 -0
  70. package/templates/src/components/WorkspaceNotFoundCard.vue +33 -0
  71. package/templates/src/components/account/settings/AccountSettingsClientElement.vue +153 -0
  72. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +77 -0
  73. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +55 -0
  74. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +125 -0
  75. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +94 -0
  76. package/templates/src/composables/useWorkspaceNotFoundState.js +48 -0
  77. package/templates/src/pages/account/index.vue +17 -0
  78. package/templates/src/pages/admin/members/index.vue +7 -0
  79. package/templates/src/pages/admin/workspace/settings/index.vue +16 -0
  80. package/templates/src/pages/console/settings/index.vue +16 -0
  81. package/templates/src/surfaces/admin/index.vue +29 -0
  82. package/templates/src/surfaces/admin/root.vue +20 -0
  83. package/templates/src/surfaces/app/index.vue +27 -0
  84. package/templates/src/surfaces/app/root.vue +20 -0
  85. package/test/bootstrap.test.js +38 -0
  86. package/test/bootstrapPlacementRuntime.test.js +991 -0
  87. package/test/errorMessageHelpers.test.js +28 -0
  88. package/test/exportsContract.test.js +39 -0
  89. package/test/menuIcons.test.js +33 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/profileSurfaceMenuLinks.test.js +207 -0
  92. package/test/refValueHelpers.test.js +14 -0
  93. package/test/scopeHelpers.test.js +57 -0
  94. package/test/surfaceAccessPolicy.test.js +129 -0
  95. package/test/theme.test.js +95 -0
  96. package/test/workspaceLinkResolver.test.js +61 -0
  97. package/test/workspaceSurfacePaths.test.js +39 -0
@@ -0,0 +1,991 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ CLIENT_MODULE_ROUTER_TOKEN,
5
+ CLIENT_MODULE_VUE_APP_TOKEN
6
+ } from "@jskit-ai/kernel/client/moduleBootstrap";
7
+ import { WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN } from "@jskit-ai/shell-web/client/placement";
8
+ import { REALTIME_SOCKET_CLIENT_TOKEN } from "@jskit-ai/realtime/client/tokens";
9
+ import { USERS_BOOTSTRAP_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
10
+ import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
11
+ import {
12
+ WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
13
+ WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND,
14
+ WORKSPACE_BOOTSTRAP_STATUS_RESOLVED,
15
+ createBootstrapPlacementRuntime
16
+ } from "../src/client/runtime/bootstrapPlacementRuntime.js";
17
+
18
+ const SHELL_GUARD_EVALUATOR_KEY = "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
19
+
20
+ function flushTasks() {
21
+ return new Promise((resolve) => {
22
+ setTimeout(resolve, 0);
23
+ });
24
+ }
25
+
26
+ test.afterEach(() => {
27
+ try {
28
+ delete globalThis[SHELL_GUARD_EVALUATOR_KEY];
29
+ } catch {}
30
+ });
31
+
32
+ function createPlacementRuntimeStub() {
33
+ const listeners = new Set();
34
+ const setCalls = [];
35
+ let context = Object.freeze({
36
+ surfaceConfig: {
37
+ tenancyMode: "workspace",
38
+ defaultSurfaceId: "app",
39
+ enabledSurfaceIds: ["app", "admin", "home"],
40
+ surfacesById: {
41
+ home: {
42
+ id: "home",
43
+ enabled: true,
44
+ pagesRoot: "",
45
+ routeBase: "/",
46
+ requiresWorkspace: false
47
+ },
48
+ app: {
49
+ id: "app",
50
+ enabled: true,
51
+ pagesRoot: "w/[workspaceSlug]",
52
+ routeBase: "/w/:workspaceSlug",
53
+ requiresWorkspace: true
54
+ },
55
+ admin: {
56
+ id: "admin",
57
+ enabled: true,
58
+ pagesRoot: "w/[workspaceSlug]/admin",
59
+ routeBase: "/w/:workspaceSlug/admin",
60
+ requiresWorkspace: true
61
+ }
62
+ }
63
+ }
64
+ });
65
+
66
+ return {
67
+ getContext() {
68
+ return context;
69
+ },
70
+ setContext(patch = {}, { replace = false, source = "" } = {}) {
71
+ context = Object.freeze(
72
+ replace
73
+ ? {
74
+ ...patch
75
+ }
76
+ : {
77
+ ...context,
78
+ ...patch
79
+ }
80
+ );
81
+ setCalls.push({
82
+ patch,
83
+ source
84
+ });
85
+ for (const listener of listeners) {
86
+ listener({
87
+ type: "context.updated",
88
+ source
89
+ });
90
+ }
91
+ return context;
92
+ },
93
+ subscribe(listener) {
94
+ if (typeof listener !== "function") {
95
+ return () => {};
96
+ }
97
+ listeners.add(listener);
98
+ return () => {
99
+ listeners.delete(listener);
100
+ };
101
+ },
102
+ setCalls
103
+ };
104
+ }
105
+
106
+ function resolvePathFromFullPath(fullPath = "/") {
107
+ const normalizedFullPath = String(fullPath || "").trim() || "/";
108
+ const queryStart = normalizedFullPath.indexOf("?");
109
+ const hashStart = normalizedFullPath.indexOf("#");
110
+ const stopIndex = [queryStart, hashStart]
111
+ .filter((index) => index >= 0)
112
+ .sort((left, right) => left - right)[0];
113
+ if (typeof stopIndex !== "number") {
114
+ return normalizedFullPath;
115
+ }
116
+ return normalizedFullPath.slice(0, stopIndex) || "/";
117
+ }
118
+
119
+ function createRouterStub(initialPath = "/w/acme/dashboard") {
120
+ const afterEachListeners = [];
121
+ const replaceCalls = [];
122
+ const normalizedInitialPath = String(initialPath || "").trim() || "/";
123
+ const router = {
124
+ currentRoute: {
125
+ value: {
126
+ path: resolvePathFromFullPath(normalizedInitialPath),
127
+ fullPath: normalizedInitialPath
128
+ }
129
+ },
130
+ afterEach(listener) {
131
+ afterEachListeners.push(listener);
132
+ return () => {
133
+ const index = afterEachListeners.indexOf(listener);
134
+ if (index >= 0) {
135
+ afterEachListeners.splice(index, 1);
136
+ }
137
+ };
138
+ },
139
+ replace(target) {
140
+ const fullPath = String(target || "").trim() || "/";
141
+ router.currentRoute.value.path = resolvePathFromFullPath(fullPath);
142
+ router.currentRoute.value.fullPath = fullPath;
143
+ replaceCalls.push(fullPath);
144
+ for (const listener of [...afterEachListeners]) {
145
+ listener();
146
+ }
147
+ return Promise.resolve();
148
+ },
149
+ push(target) {
150
+ return router.replace(target);
151
+ },
152
+ emitAfterEach() {
153
+ for (const listener of [...afterEachListeners]) {
154
+ listener();
155
+ }
156
+ },
157
+ replaceCalls
158
+ };
159
+
160
+ return router;
161
+ }
162
+
163
+ function createSocketStub() {
164
+ const listeners = new Map();
165
+ return {
166
+ on(eventName, handler) {
167
+ listeners.set(eventName, handler);
168
+ },
169
+ off(eventName, handler) {
170
+ if (listeners.get(eventName) === handler) {
171
+ listeners.delete(eventName);
172
+ }
173
+ },
174
+ emit(eventName, payload) {
175
+ listeners.get(eventName)?.(payload);
176
+ }
177
+ };
178
+ }
179
+
180
+ function createAppStub(records = {}) {
181
+ const registry = new Map();
182
+ for (const key of Reflect.ownKeys(records)) {
183
+ registry.set(key, records[key]);
184
+ }
185
+ return {
186
+ has(token) {
187
+ return registry.has(token);
188
+ },
189
+ make(token) {
190
+ return registry.get(token);
191
+ },
192
+ warn() {}
193
+ };
194
+ }
195
+
196
+ function createVuetifyThemeController(initial = "light") {
197
+ return {
198
+ global: {
199
+ name: {
200
+ value: initial
201
+ }
202
+ }
203
+ };
204
+ }
205
+
206
+ function createVueAppWithThemeController(themeController) {
207
+ return {
208
+ _context: {
209
+ provides: {
210
+ [ThemeSymbol]: themeController
211
+ }
212
+ }
213
+ };
214
+ }
215
+
216
+ test("bootstrap placement runtime writes user/workspace/permissions into placement context", async () => {
217
+ const placementRuntime = createPlacementRuntimeStub();
218
+ const router = createRouterStub("/w/acme/dashboard");
219
+ const fetchCalls = [];
220
+ const runtime = createBootstrapPlacementRuntime({
221
+ app: createAppStub({
222
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
223
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
224
+ }),
225
+ fetchBootstrap: async (workspaceSlug) => {
226
+ fetchCalls.push(workspaceSlug);
227
+ return {
228
+ session: {
229
+ authenticated: true,
230
+ userId: 7
231
+ },
232
+ profile: {
233
+ displayName: "Ada Lovelace",
234
+ email: "ADA@EXAMPLE.COM",
235
+ avatar: {
236
+ effectiveUrl: "https://cdn.example.com/ada.png"
237
+ }
238
+ },
239
+ app: {
240
+ features: {
241
+ workspaceInvites: true
242
+ }
243
+ },
244
+ pendingInvites: [
245
+ { id: 1, workspaceId: 1, token: "a" },
246
+ { id: 2, workspaceId: 2, token: "b" }
247
+ ],
248
+ workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
249
+ permissions: ["workspace.settings.view"]
250
+ };
251
+ }
252
+ });
253
+
254
+ await runtime.initialize();
255
+ const context = placementRuntime.getContext();
256
+
257
+ assert.deepEqual(fetchCalls, ["acme"]);
258
+ assert.equal(context.workspace?.slug, "acme");
259
+ assert.equal(Array.isArray(context.workspaces), true);
260
+ assert.equal(context.workspaces.length, 1);
261
+ assert.deepEqual(context.permissions, ["workspace.settings.view"]);
262
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
263
+ assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
264
+ assert.deepEqual(context.user, {
265
+ id: 7,
266
+ displayName: "Ada Lovelace",
267
+ name: "Ada Lovelace",
268
+ email: "ada@example.com",
269
+ avatarUrl: "https://cdn.example.com/ada.png"
270
+ });
271
+ assert.equal(context.workspaceInvitesEnabled, true);
272
+ assert.equal(context.pendingInvitesCount, 2);
273
+ });
274
+
275
+ test("bootstrap placement runtime resolves workspace slug from pathname when surface config is missing", async () => {
276
+ const placementRuntime = createPlacementRuntimeStub();
277
+ placementRuntime.setContext({}, { replace: true, source: "test.clear" });
278
+ const router = createRouterStub("/w/acme/admin");
279
+ const fetchCalls = [];
280
+ const runtime = createBootstrapPlacementRuntime({
281
+ app: createAppStub({
282
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
283
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
284
+ }),
285
+ fetchBootstrap: async (workspaceSlug) => {
286
+ fetchCalls.push(workspaceSlug);
287
+ return {
288
+ session: {
289
+ authenticated: true,
290
+ userId: 1
291
+ },
292
+ profile: {
293
+ displayName: "User",
294
+ email: "user@example.com",
295
+ avatar: {
296
+ effectiveUrl: ""
297
+ }
298
+ },
299
+ workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
300
+ permissions: ["workspace.settings.view"]
301
+ };
302
+ }
303
+ });
304
+
305
+ await runtime.initialize();
306
+
307
+ assert.deepEqual(fetchCalls, ["acme"]);
308
+ assert.deepEqual(placementRuntime.getContext().permissions, ["workspace.settings.view"]);
309
+ });
310
+
311
+ test("bootstrap placement runtime does not mutate placement auth context", async () => {
312
+ const placementRuntime = createPlacementRuntimeStub();
313
+ placementRuntime.setContext(
314
+ {
315
+ auth: {
316
+ authenticated: true,
317
+ oauthDefaultProvider: "github",
318
+ oauthProviders: [{ id: "github", label: "GitHub" }]
319
+ }
320
+ },
321
+ { source: "test.seed" }
322
+ );
323
+ const router = createRouterStub("/w/acme/dashboard");
324
+ const runtime = createBootstrapPlacementRuntime({
325
+ app: createAppStub({
326
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
327
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
328
+ }),
329
+ fetchBootstrap: async () => {
330
+ return {
331
+ session: {
332
+ authenticated: true,
333
+ userId: 9
334
+ },
335
+ profile: {
336
+ displayName: "User",
337
+ email: "user@example.com",
338
+ avatar: {
339
+ effectiveUrl: ""
340
+ }
341
+ },
342
+ workspaces: [{ id: 1, slug: "acme", name: "Workspace" }],
343
+ permissions: []
344
+ };
345
+ }
346
+ });
347
+
348
+ await runtime.initialize();
349
+ assert.deepEqual(placementRuntime.getContext().auth, {
350
+ authenticated: true,
351
+ oauthDefaultProvider: "github",
352
+ oauthProviders: [{ id: "github", label: "GitHub" }]
353
+ });
354
+ });
355
+
356
+ test("bootstrap placement runtime refetches on route changes and users.bootstrap.changed events", async () => {
357
+ const placementRuntime = createPlacementRuntimeStub();
358
+ const router = createRouterStub("/w/acme/dashboard");
359
+ const socket = createSocketStub();
360
+ const fetchCalls = [];
361
+ const runtime = createBootstrapPlacementRuntime({
362
+ app: createAppStub({
363
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
364
+ [CLIENT_MODULE_ROUTER_TOKEN]: router,
365
+ [REALTIME_SOCKET_CLIENT_TOKEN]: socket
366
+ }),
367
+ fetchBootstrap: async (workspaceSlug) => {
368
+ fetchCalls.push(workspaceSlug);
369
+ return {
370
+ session: {
371
+ authenticated: true,
372
+ userId: 1
373
+ },
374
+ profile: {
375
+ displayName: "User",
376
+ email: "user@example.com",
377
+ avatar: {
378
+ effectiveUrl: ""
379
+ }
380
+ },
381
+ workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
382
+ permissions: []
383
+ };
384
+ }
385
+ });
386
+
387
+ await runtime.initialize();
388
+ assert.deepEqual(fetchCalls, ["acme"]);
389
+
390
+ router.currentRoute.value.path = "/w/acme/customers";
391
+ router.currentRoute.value.fullPath = "/w/acme/customers";
392
+ router.emitAfterEach();
393
+ await flushTasks();
394
+ assert.deepEqual(fetchCalls, ["acme"]);
395
+
396
+ router.currentRoute.value.path = "/w/zen/dashboard";
397
+ router.currentRoute.value.fullPath = "/w/zen/dashboard";
398
+ router.emitAfterEach();
399
+ await flushTasks();
400
+ assert.deepEqual(fetchCalls, ["acme", "zen"]);
401
+
402
+ socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
403
+ await flushTasks();
404
+ assert.deepEqual(fetchCalls, ["acme", "zen", "zen"]);
405
+ });
406
+
407
+ test("bootstrap placement runtime refetches when auth context changes", async () => {
408
+ const placementRuntime = createPlacementRuntimeStub();
409
+ const router = createRouterStub("/w/acme/dashboard");
410
+ const fetchCalls = [];
411
+ const runtime = createBootstrapPlacementRuntime({
412
+ app: createAppStub({
413
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
414
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
415
+ }),
416
+ fetchBootstrap: async (workspaceSlug) => {
417
+ fetchCalls.push(workspaceSlug);
418
+ return {
419
+ session: {
420
+ authenticated: true,
421
+ userId: 1
422
+ },
423
+ profile: {
424
+ displayName: "User",
425
+ email: "user@example.com",
426
+ avatar: {
427
+ effectiveUrl: ""
428
+ }
429
+ },
430
+ workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
431
+ permissions: []
432
+ };
433
+ }
434
+ });
435
+
436
+ await runtime.initialize();
437
+ assert.deepEqual(fetchCalls, ["acme"]);
438
+
439
+ placementRuntime.setContext(
440
+ {
441
+ auth: {
442
+ authenticated: true,
443
+ oauthDefaultProvider: "",
444
+ oauthProviders: []
445
+ }
446
+ },
447
+ {
448
+ source: "test.auth"
449
+ }
450
+ );
451
+ await flushTasks();
452
+ await flushTasks();
453
+ assert.deepEqual(fetchCalls, ["acme", "acme"]);
454
+ });
455
+
456
+ test("bootstrap placement runtime forces light theme for unauthenticated bootstrap payloads", async () => {
457
+ const placementRuntime = createPlacementRuntimeStub();
458
+ const router = createRouterStub("/auth/login");
459
+ const themeController = createVuetifyThemeController("dark");
460
+ const runtime = createBootstrapPlacementRuntime({
461
+ app: createAppStub({
462
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
463
+ [CLIENT_MODULE_ROUTER_TOKEN]: router,
464
+ [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
465
+ }),
466
+ fetchBootstrap: async () => {
467
+ return {
468
+ session: {
469
+ authenticated: false
470
+ },
471
+ workspaces: [],
472
+ permissions: []
473
+ };
474
+ }
475
+ });
476
+
477
+ await runtime.initialize();
478
+ assert.equal(themeController.global.name.value, "light");
479
+ });
480
+
481
+ test("bootstrap placement runtime reapplies theme when bootstrap payload changes", async () => {
482
+ const placementRuntime = createPlacementRuntimeStub();
483
+ const router = createRouterStub("/w/acme/dashboard");
484
+ const socket = createSocketStub();
485
+ const themeController = createVuetifyThemeController("light");
486
+ let fetchCount = 0;
487
+ const runtime = createBootstrapPlacementRuntime({
488
+ app: createAppStub({
489
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
490
+ [CLIENT_MODULE_ROUTER_TOKEN]: router,
491
+ [REALTIME_SOCKET_CLIENT_TOKEN]: socket,
492
+ [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
493
+ }),
494
+ fetchBootstrap: async (workspaceSlug) => {
495
+ fetchCount += 1;
496
+ return {
497
+ session: {
498
+ authenticated: true,
499
+ userId: 1
500
+ },
501
+ profile: {
502
+ displayName: "User",
503
+ email: "user@example.com",
504
+ avatar: {
505
+ effectiveUrl: ""
506
+ }
507
+ },
508
+ userSettings: {
509
+ theme: fetchCount === 1 ? "dark" : "light"
510
+ },
511
+ workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
512
+ permissions: []
513
+ };
514
+ }
515
+ });
516
+
517
+ await runtime.initialize();
518
+ assert.equal(themeController.global.name.value, "dark");
519
+
520
+ socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
521
+ await flushTasks();
522
+ assert.equal(themeController.global.name.value, "light");
523
+ });
524
+
525
+ test("bootstrap placement runtime marks workspace slug as not_found and clears workspace context on 404", async () => {
526
+ const placementRuntime = createPlacementRuntimeStub();
527
+ placementRuntime.setContext(
528
+ {
529
+ workspace: { id: 1, slug: "acme", name: "Acme Workspace" },
530
+ workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
531
+ permissions: ["workspace.settings.view"]
532
+ },
533
+ { source: "test.seed" }
534
+ );
535
+
536
+ const runtime = createBootstrapPlacementRuntime({
537
+ app: createAppStub({
538
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
539
+ [CLIENT_MODULE_ROUTER_TOKEN]: createRouterStub("/w/acme/dashboard")
540
+ }),
541
+ fetchBootstrap: async () => {
542
+ const error = new Error("Workspace not found.");
543
+ error.status = 404;
544
+ throw error;
545
+ }
546
+ });
547
+
548
+ await runtime.initialize();
549
+
550
+ const context = placementRuntime.getContext();
551
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
552
+ assert.equal(context.workspaceBootstrapStatuses?.acme, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
553
+ assert.equal(context.workspace, null);
554
+ assert.deepEqual(context.workspaces, []);
555
+ assert.deepEqual(context.permissions, []);
556
+ assert.equal(context.user, null);
557
+ assert.equal(context.pendingInvitesCount, 0);
558
+ assert.equal(context.workspaceInvitesEnabled, false);
559
+ });
560
+
561
+ test("bootstrap placement runtime updates status per workspace slug across route changes", async () => {
562
+ const placementRuntime = createPlacementRuntimeStub();
563
+ const router = createRouterStub("/w/acme/dashboard");
564
+ const runtime = createBootstrapPlacementRuntime({
565
+ app: createAppStub({
566
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
567
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
568
+ }),
569
+ fetchBootstrap: async (workspaceSlug) => {
570
+ if (workspaceSlug === "zen") {
571
+ const error = new Error("Workspace not found.");
572
+ error.status = 404;
573
+ throw error;
574
+ }
575
+
576
+ return {
577
+ session: {
578
+ authenticated: true,
579
+ userId: 1
580
+ },
581
+ profile: {
582
+ displayName: "User",
583
+ email: "user@example.com",
584
+ avatar: {
585
+ effectiveUrl: ""
586
+ }
587
+ },
588
+ workspaces: [{ id: 1, slug: workspaceSlug || "acme", name: "Workspace" }],
589
+ permissions: []
590
+ };
591
+ }
592
+ });
593
+
594
+ await runtime.initialize();
595
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_RESOLVED);
596
+
597
+ router.currentRoute.value.path = "/w/zen/dashboard";
598
+ router.currentRoute.value.fullPath = "/w/zen/dashboard";
599
+ router.emitAfterEach();
600
+ await flushTasks();
601
+
602
+ const context = placementRuntime.getContext();
603
+ assert.equal(runtime.getWorkspaceBootstrapStatus("zen"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
604
+ assert.equal(context.workspaceBootstrapStatuses?.zen, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
605
+ assert.equal(context.workspace, null);
606
+ });
607
+
608
+ test("bootstrap placement runtime uses requestedWorkspace status and keeps global workspace list on inaccessible slug", async () => {
609
+ const placementRuntime = createPlacementRuntimeStub();
610
+ const router = createRouterStub("/w/tonymobily");
611
+ const runtime = createBootstrapPlacementRuntime({
612
+ app: createAppStub({
613
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
614
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
615
+ }),
616
+ fetchBootstrap: async () => {
617
+ return {
618
+ session: {
619
+ authenticated: true,
620
+ userId: 4
621
+ },
622
+ profile: {
623
+ displayName: "Chiara",
624
+ email: "chiara@example.com",
625
+ avatar: {
626
+ effectiveUrl: ""
627
+ }
628
+ },
629
+ workspaces: [{ id: 3, slug: "chiaramobily", name: "Chiara Workspace" }],
630
+ requestedWorkspace: {
631
+ slug: "tonymobily",
632
+ status: "forbidden"
633
+ },
634
+ permissions: []
635
+ };
636
+ }
637
+ });
638
+
639
+ await runtime.initialize();
640
+
641
+ const context = placementRuntime.getContext();
642
+ assert.equal(runtime.getWorkspaceBootstrapStatus("tonymobily"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
643
+ assert.equal(context.workspaceBootstrapStatuses?.tonymobily, WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
644
+ assert.equal(context.workspace, null);
645
+ assert.equal(Array.isArray(context.workspaces), true);
646
+ assert.equal(context.workspaces.length, 1);
647
+ assert.equal(context.workspaces[0]?.slug, "chiaramobily");
648
+ });
649
+
650
+ test("bootstrap placement runtime uses requestedWorkspace=not_found without forcing forbidden fallback", async () => {
651
+ const placementRuntime = createPlacementRuntimeStub();
652
+ const router = createRouterStub("/w/missing");
653
+ const runtime = createBootstrapPlacementRuntime({
654
+ app: createAppStub({
655
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
656
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
657
+ }),
658
+ fetchBootstrap: async () => {
659
+ return {
660
+ session: {
661
+ authenticated: true,
662
+ userId: 1
663
+ },
664
+ profile: {
665
+ displayName: "User",
666
+ email: "user@example.com",
667
+ avatar: {
668
+ effectiveUrl: ""
669
+ }
670
+ },
671
+ workspaces: [{ id: 1, slug: "acme", name: "Acme Workspace" }],
672
+ requestedWorkspace: {
673
+ slug: "missing",
674
+ status: "not_found"
675
+ },
676
+ permissions: []
677
+ };
678
+ }
679
+ });
680
+
681
+ await runtime.initialize();
682
+
683
+ const context = placementRuntime.getContext();
684
+ assert.equal(runtime.getWorkspaceBootstrapStatus("missing"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
685
+ assert.equal(context.workspaceBootstrapStatuses?.missing, WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
686
+ assert.equal(Array.isArray(context.workspaces), true);
687
+ assert.equal(context.workspaces.length, 1);
688
+ assert.equal(context.workspaces[0]?.slug, "acme");
689
+ });
690
+
691
+ test("bootstrap placement runtime guard wrapper preserves delegated deny outcomes", async () => {
692
+ const placementRuntime = createPlacementRuntimeStub();
693
+ const router = createRouterStub("/w/acme/dashboard");
694
+ const delegatedOutcome = Object.freeze({
695
+ allow: false,
696
+ redirectTo: "/auth/login?returnTo=%2Fw%2Facme%2Fdashboard",
697
+ reason: "auth-required"
698
+ });
699
+ globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
700
+
701
+ const runtime = createBootstrapPlacementRuntime({
702
+ app: createAppStub({
703
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
704
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
705
+ }),
706
+ fetchBootstrap: async () => {
707
+ return {
708
+ session: {
709
+ authenticated: true,
710
+ userId: 1
711
+ },
712
+ workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
713
+ permissions: []
714
+ };
715
+ }
716
+ });
717
+
718
+ await runtime.initialize();
719
+ const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
720
+ const outcome = evaluator({
721
+ guard: {
722
+ policy: "authenticated"
723
+ },
724
+ context: {
725
+ to: {
726
+ path: "/w/acme/dashboard",
727
+ fullPath: "/w/acme/dashboard"
728
+ },
729
+ location: {
730
+ pathname: "/w/acme/dashboard",
731
+ search: ""
732
+ }
733
+ }
734
+ });
735
+
736
+ assert.deepEqual(outcome, delegatedOutcome);
737
+ });
738
+
739
+ test("bootstrap placement runtime guard wrapper blocks forbidden workspace routes", async () => {
740
+ const placementRuntime = createPlacementRuntimeStub();
741
+ const router = createRouterStub("/w/acme/dashboard");
742
+ globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => ({ allow: true, redirectTo: "", reason: "" });
743
+
744
+ const runtime = createBootstrapPlacementRuntime({
745
+ app: createAppStub({
746
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
747
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
748
+ }),
749
+ fetchBootstrap: async () => {
750
+ return {
751
+ session: {
752
+ authenticated: true,
753
+ userId: 1
754
+ },
755
+ workspaces: [],
756
+ permissions: []
757
+ };
758
+ }
759
+ });
760
+
761
+ await runtime.initialize();
762
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
763
+
764
+ const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
765
+ const outcome = evaluator({
766
+ guard: {
767
+ policy: "authenticated"
768
+ },
769
+ context: {
770
+ to: {
771
+ path: "/w/acme/dashboard",
772
+ fullPath: "/w/acme/dashboard?tab=general"
773
+ },
774
+ location: {
775
+ pathname: "/w/acme/dashboard",
776
+ search: "?tab=general"
777
+ }
778
+ }
779
+ });
780
+
781
+ assert.equal(outcome.allow, false);
782
+ assert.equal(outcome.reason, "workspace-forbidden");
783
+ assert.equal(outcome.redirectTo, "/w/acme");
784
+ });
785
+
786
+ test("bootstrap placement runtime guard wrapper redirects nested not_found routes to workspace surface root", async () => {
787
+ const placementRuntime = createPlacementRuntimeStub();
788
+ const router = createRouterStub("/w/acme/dashboard");
789
+ const runtime = createBootstrapPlacementRuntime({
790
+ app: createAppStub({
791
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
792
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
793
+ }),
794
+ fetchBootstrap: async () => {
795
+ const error = new Error("Not found");
796
+ error.status = 404;
797
+ throw error;
798
+ }
799
+ });
800
+
801
+ await runtime.initialize();
802
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
803
+
804
+ const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
805
+ const nestedOutcome = evaluator({
806
+ guard: {
807
+ policy: "authenticated"
808
+ },
809
+ context: {
810
+ to: {
811
+ path: "/w/acme/projects",
812
+ fullPath: "/w/acme/projects"
813
+ },
814
+ location: {
815
+ pathname: "/w/acme/projects",
816
+ search: ""
817
+ }
818
+ }
819
+ });
820
+ assert.deepEqual(nestedOutcome, {
821
+ allow: false,
822
+ redirectTo: "/w/acme",
823
+ reason: "workspace-not-found"
824
+ });
825
+
826
+ const rootOutcome = evaluator({
827
+ guard: {
828
+ policy: "authenticated"
829
+ },
830
+ context: {
831
+ to: {
832
+ path: "/w/acme",
833
+ fullPath: "/w/acme"
834
+ },
835
+ location: {
836
+ pathname: "/w/acme",
837
+ search: ""
838
+ }
839
+ }
840
+ });
841
+ assert.equal(rootOutcome, true);
842
+ });
843
+
844
+ test("bootstrap placement runtime redirects admin nested route to admin root when workspace is not_found", async () => {
845
+ const placementRuntime = createPlacementRuntimeStub();
846
+ const router = createRouterStub("/w/acme/admin/workspace/settings");
847
+ const runtime = createBootstrapPlacementRuntime({
848
+ app: createAppStub({
849
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
850
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
851
+ }),
852
+ fetchBootstrap: async () => {
853
+ const error = new Error("Not found");
854
+ error.status = 404;
855
+ throw error;
856
+ }
857
+ });
858
+
859
+ await runtime.initialize();
860
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_NOT_FOUND);
861
+ assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
862
+ });
863
+
864
+ test("bootstrap placement runtime redirects forbidden workspace route to workspace surface root", async () => {
865
+ const placementRuntime = createPlacementRuntimeStub();
866
+ const router = createRouterStub("/w/acme/admin/workspace/settings?tab=general");
867
+ const runtime = createBootstrapPlacementRuntime({
868
+ app: createAppStub({
869
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
870
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
871
+ }),
872
+ fetchBootstrap: async () => {
873
+ const error = new Error("Forbidden");
874
+ error.status = 403;
875
+ throw error;
876
+ }
877
+ });
878
+
879
+ await runtime.initialize();
880
+ assert.equal(runtime.getWorkspaceBootstrapStatus("acme"), WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN);
881
+ assert.deepEqual(router.replaceCalls, ["/w/acme/admin"]);
882
+ });
883
+
884
+ test("bootstrap placement runtime enforces surface access policies after bootstrap refresh", async () => {
885
+ const placementRuntime = createPlacementRuntimeStub();
886
+ placementRuntime.setContext({
887
+ auth: {
888
+ authenticated: true
889
+ },
890
+ surfaceAccessPolicies: {
891
+ public: {},
892
+ console_owner: {
893
+ requireAuth: true,
894
+ requireFlagsAll: ["console_owner"]
895
+ }
896
+ },
897
+ surfaceConfig: {
898
+ tenancyMode: "workspace",
899
+ defaultSurfaceId: "home",
900
+ enabledSurfaceIds: ["home", "console"],
901
+ surfacesById: {
902
+ home: {
903
+ id: "home",
904
+ enabled: true,
905
+ pagesRoot: "home",
906
+ routeBase: "/home",
907
+ requiresWorkspace: false,
908
+ accessPolicyId: "public"
909
+ },
910
+ console: {
911
+ id: "console",
912
+ enabled: true,
913
+ pagesRoot: "console",
914
+ routeBase: "/console",
915
+ requiresWorkspace: false,
916
+ accessPolicyId: "console_owner"
917
+ }
918
+ }
919
+ }
920
+ });
921
+ const router = createRouterStub("/console");
922
+ const runtime = createBootstrapPlacementRuntime({
923
+ app: createAppStub({
924
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
925
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
926
+ }),
927
+ fetchBootstrap: async () => {
928
+ return {
929
+ session: {
930
+ authenticated: true,
931
+ userId: 1
932
+ },
933
+ workspaces: [],
934
+ permissions: [],
935
+ surfaceAccess: {
936
+ consoleowner: false
937
+ }
938
+ };
939
+ }
940
+ });
941
+
942
+ await runtime.initialize();
943
+ assert.deepEqual(router.replaceCalls, ["/home"]);
944
+ });
945
+
946
+ test("bootstrap placement runtime captures guard evaluator assignments after initialization", async () => {
947
+ const placementRuntime = createPlacementRuntimeStub();
948
+ const router = createRouterStub("/w/acme/dashboard");
949
+ const runtime = createBootstrapPlacementRuntime({
950
+ app: createAppStub({
951
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
952
+ [CLIENT_MODULE_ROUTER_TOKEN]: router
953
+ }),
954
+ fetchBootstrap: async () => {
955
+ return {
956
+ session: {
957
+ authenticated: true,
958
+ userId: 1
959
+ },
960
+ workspaces: [{ id: 1, slug: "acme", name: "Acme" }],
961
+ permissions: []
962
+ };
963
+ }
964
+ });
965
+
966
+ await runtime.initialize();
967
+ const delegatedOutcome = {
968
+ allow: false,
969
+ redirectTo: "/auth/login",
970
+ reason: "auth-required"
971
+ };
972
+ globalThis[SHELL_GUARD_EVALUATOR_KEY] = () => delegatedOutcome;
973
+
974
+ const evaluator = globalThis[SHELL_GUARD_EVALUATOR_KEY];
975
+ const outcome = evaluator({
976
+ guard: {
977
+ policy: "authenticated"
978
+ },
979
+ context: {
980
+ to: {
981
+ path: "/w/acme/dashboard",
982
+ fullPath: "/w/acme/dashboard"
983
+ },
984
+ location: {
985
+ pathname: "/w/acme/dashboard",
986
+ search: ""
987
+ }
988
+ }
989
+ });
990
+ assert.deepEqual(outcome, delegatedOutcome);
991
+ });