minutework 0.1.40 → 0.1.42
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/EXTERNAL_ALPHA.md +17 -1
- package/README.md +21 -1
- package/assets/claude-local/bundle.json +2 -1
- package/assets/claude-local/preambles/base.md +13 -0
- package/assets/claude-local/preambles/mobile.md +17 -0
- package/assets/claude-local/preambles/tenant.md +17 -0
- package/assets/claude-local/preambles/vuilder.md +29 -0
- package/assets/claude-local/skills/README.md +5 -0
- package/assets/claude-local/skills/app-pack-authoring/SKILL.md +15 -0
- package/assets/claude-local/skills/generated-workspace-architecture/SKILL.md +25 -9
- package/assets/claude-local/skills/shell-architecture/SKILL.md +15 -0
- package/assets/claude-local/skills/vuilder-public-site-authoring/SKILL.md +10 -4
- package/assets/claude-local/skills/vuilder-workspace-architecture/SKILL.md +78 -0
- package/assets/templates/vuilder-public-site/.env.example +13 -0
- package/assets/templates/vuilder-public-site/README.md +15 -0
- package/assets/templates/vuilder-public-site/eslint.config.mjs +6 -0
- package/assets/templates/vuilder-public-site/next-env.d.ts +4 -0
- package/assets/templates/vuilder-public-site/next.config.mjs +28 -0
- package/assets/templates/vuilder-public-site/package.json +40 -0
- package/assets/templates/vuilder-public-site/postcss.config.mjs +5 -0
- package/assets/templates/vuilder-public-site/src/app/api/onboarding/start/route.ts +19 -0
- package/assets/templates/vuilder-public-site/src/app/blog/[slug]/page.tsx +25 -0
- package/assets/templates/vuilder-public-site/src/app/blog/page.tsx +26 -0
- package/assets/templates/vuilder-public-site/src/app/globals.css +103 -0
- package/assets/templates/vuilder-public-site/src/app/layout.tsx +18 -0
- package/assets/templates/vuilder-public-site/src/app/onboarding/[[...step]]/page.tsx +39 -0
- package/assets/templates/vuilder-public-site/src/app/page.tsx +43 -0
- package/assets/templates/vuilder-public-site/src/lib/env.server.test.ts +47 -0
- package/assets/templates/vuilder-public-site/src/lib/env.server.ts +92 -0
- package/assets/templates/vuilder-public-site/src/lib/platform.server.ts +47 -0
- package/assets/templates/vuilder-public-site/src/lib/public-dj.server.ts +86 -0
- package/assets/templates/vuilder-public-site/src/lib/public-dj.test.ts +8 -0
- package/assets/templates/vuilder-public-site/src/lib/routes.test.ts +13 -0
- package/assets/templates/vuilder-public-site/src/lib/routes.ts +12 -0
- package/assets/templates/vuilder-public-site/template.json +21 -0
- package/assets/templates/vuilder-public-site/test/server-only-stub.ts +1 -0
- package/assets/templates/vuilder-public-site/tools/env/check-dev-env.mjs +109 -0
- package/assets/templates/vuilder-public-site/tools/env/check-dev-env.test.ts +49 -0
- package/assets/templates/vuilder-public-site/tools/template/validate-template.mjs +44 -0
- package/assets/templates/vuilder-public-site/tsconfig.json +23 -0
- package/assets/templates/vuilder-public-site/vitest.config.ts +15 -0
- package/assets/templates/vuilder-shell/.env.example +8 -0
- package/assets/templates/vuilder-shell/.storybook/main.ts +19 -0
- package/assets/templates/vuilder-shell/.storybook/preview.tsx +38 -0
- package/assets/templates/vuilder-shell/README.md +49 -0
- package/assets/templates/vuilder-shell/components.json +21 -0
- package/assets/templates/vuilder-shell/eslint.config.mjs +41 -0
- package/assets/templates/vuilder-shell/next-env.d.ts +6 -0
- package/assets/templates/vuilder-shell/next.config.mjs +33 -0
- package/assets/templates/vuilder-shell/package.json +61 -0
- package/assets/templates/vuilder-shell/postcss.config.mjs +8 -0
- package/assets/templates/vuilder-shell/public/.gitkeep +1 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.test.ts +105 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.ts +63 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.test.ts +63 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.ts +24 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/session/route.test.ts +70 -0
- package/assets/templates/vuilder-shell/src/app/api/auth/session/route.ts +27 -0
- package/assets/templates/vuilder-shell/src/app/app/layout.tsx +17 -0
- package/assets/templates/vuilder-shell/src/app/app/page.tsx +30 -0
- package/assets/templates/vuilder-shell/src/app/blog/[slug]/page.tsx +15 -0
- package/assets/templates/vuilder-shell/src/app/blog/page.tsx +15 -0
- package/assets/templates/vuilder-shell/src/app/docs/[...slug]/page.tsx +15 -0
- package/assets/templates/vuilder-shell/src/app/docs/page.tsx +15 -0
- package/assets/templates/vuilder-shell/src/app/globals.css +70 -0
- package/assets/templates/vuilder-shell/src/app/layout.tsx +69 -0
- package/assets/templates/vuilder-shell/src/app/login/route.test.ts +33 -0
- package/assets/templates/vuilder-shell/src/app/login/route.ts +21 -0
- package/assets/templates/vuilder-shell/src/app/page.tsx +16 -0
- package/assets/templates/vuilder-shell/src/app/pricing/page.tsx +15 -0
- package/assets/templates/vuilder-shell/src/app/providers.tsx +25 -0
- package/assets/templates/vuilder-shell/src/app/robots.ts +21 -0
- package/assets/templates/vuilder-shell/src/app/sitemap.ts +33 -0
- package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/connect/page.tsx +31 -0
- package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/page.tsx +54 -0
- package/assets/templates/vuilder-shell/src/components/ui/button.tsx +59 -0
- package/assets/templates/vuilder-shell/src/components/ui/input.tsx +21 -0
- package/assets/templates/vuilder-shell/src/design-system/docs/governance.mdx +26 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.stories.tsx +48 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.tsx +26 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.stories.tsx +26 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.tsx +35 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
- package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
- package/assets/templates/vuilder-shell/src/design-system/primitives/button.stories.tsx +37 -0
- package/assets/templates/vuilder-shell/src/design-system/primitives/button.ts +1 -0
- package/assets/templates/vuilder-shell/src/design-system/primitives/input.stories.tsx +26 -0
- package/assets/templates/vuilder-shell/src/design-system/primitives/input.ts +1 -0
- package/assets/templates/vuilder-shell/src/design-system/recipes/chrome.ts +28 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/foundation.css +31 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/index.css +3 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.json +85 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.ts +87 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/semantic.css +105 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/theme.css +59 -0
- package/assets/templates/vuilder-shell/src/design-system/tokens/tokens.stories.tsx +71 -0
- package/assets/templates/vuilder-shell/src/features/dashboard/components/tenant-dashboard.tsx +134 -0
- package/assets/templates/vuilder-shell/src/features/public-shell/components/static-public-page.tsx +58 -0
- package/assets/templates/vuilder-shell/src/features/shell/components/authenticated-app-layout-shell.tsx +84 -0
- package/assets/templates/vuilder-shell/src/features/shell/components/private-app-shell.tsx +22 -0
- package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-connect-screen.tsx +89 -0
- package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-workspace-screen.tsx +49 -0
- package/assets/templates/vuilder-shell/src/lib/app-routes.test.ts +37 -0
- package/assets/templates/vuilder-shell/src/lib/app-routes.ts +86 -0
- package/assets/templates/vuilder-shell/src/lib/auth-routes.server.test.ts +26 -0
- package/assets/templates/vuilder-shell/src/lib/auth-routes.server.ts +53 -0
- package/assets/templates/vuilder-shell/src/lib/http/same-origin.test.ts +23 -0
- package/assets/templates/vuilder-shell/src/lib/http/same-origin.ts +18 -0
- package/assets/templates/vuilder-shell/src/lib/platform/client.server.test.ts +201 -0
- package/assets/templates/vuilder-shell/src/lib/platform/client.server.ts +540 -0
- package/assets/templates/vuilder-shell/src/lib/platform/contracts.ts +190 -0
- package/assets/templates/vuilder-shell/src/lib/platform/endpoints.server.ts +29 -0
- package/assets/templates/vuilder-shell/src/lib/platform/env.server.ts +82 -0
- package/assets/templates/vuilder-shell/src/lib/platform/route-response.ts +33 -0
- package/assets/templates/vuilder-shell/src/lib/platform/session.server.ts +145 -0
- package/assets/templates/vuilder-shell/src/lib/public-site.test.ts +20 -0
- package/assets/templates/vuilder-shell/src/lib/public-site.ts +48 -0
- package/assets/templates/vuilder-shell/src/lib/theme-config.ts +10 -0
- package/assets/templates/vuilder-shell/src/lib/theme.tsx +159 -0
- package/assets/templates/vuilder-shell/src/lib/utils.ts +6 -0
- package/assets/templates/vuilder-shell/template.json +28 -0
- package/assets/templates/vuilder-shell/template.schema.json +171 -0
- package/assets/templates/vuilder-shell/test/server-only-stub.ts +1 -0
- package/assets/templates/vuilder-shell/tools/design-system/build-token-manifest.mjs +3 -0
- package/assets/templates/vuilder-shell/tools/design-system/check-imports.mjs +9 -0
- package/assets/templates/vuilder-shell/tools/design-system/check-stories.mjs +9 -0
- package/assets/templates/vuilder-shell/tools/design-system/check-values.mjs +9 -0
- package/assets/templates/vuilder-shell/tools/design-system/checks.mjs +238 -0
- package/assets/templates/vuilder-shell/tools/design-system/eslint-plugin-design-system.mjs +184 -0
- package/assets/templates/vuilder-shell/tools/design-system/playwright.config.mjs +34 -0
- package/assets/templates/vuilder-shell/tools/design-system/run-checks.mjs +22 -0
- package/assets/templates/vuilder-shell/tools/design-system/shared.mjs +166 -0
- package/assets/templates/vuilder-shell/tools/design-system/visual.spec.ts +41 -0
- package/assets/templates/vuilder-shell/tools/template/validate-route-contract.mjs +373 -0
- package/assets/templates/vuilder-shell/tools/template/validate-template.mjs +45 -0
- package/assets/templates/vuilder-shell/tools/template/with-public-site-fixture.mjs +45 -0
- package/assets/templates/vuilder-shell/tsconfig.json +42 -0
- package/assets/templates/vuilder-shell/vitest.config.ts +23 -0
- package/dist/auth.js +97 -31
- package/dist/auth.js.map +1 -1
- package/dist/deploy-state.d.ts +1 -0
- package/dist/deploy-state.js.map +1 -1
- package/dist/deploy.js +18 -4
- package/dist/deploy.js.map +1 -1
- package/dist/developer-client.d.ts +2 -1
- package/dist/developer-client.js.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/init-prompt.js +21 -13
- package/dist/init-prompt.js.map +1 -1
- package/dist/init.d.ts +3 -1
- package/dist/init.js +105 -13
- package/dist/init.js.map +1 -1
- package/dist/orchestrator-context.js +17 -5
- package/dist/orchestrator-context.js.map +1 -1
- package/dist/orchestrator-state.d.ts +2 -2
- package/dist/orchestrator-state.js.map +1 -1
- package/dist/publish.js +12 -2
- package/dist/publish.js.map +1 -1
- package/dist/sandbox.js +11 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/state.d.ts +2 -0
- package/dist/state.js +9 -0
- package/dist/state.js.map +1 -1
- package/dist/workspace-assets.d.ts +13 -0
- package/dist/workspace-assets.js +86 -5
- package/dist/workspace-assets.js.map +1 -1
- package/dist/workspace-bootstrap.d.ts +47 -2
- package/dist/workspace-bootstrap.js +45 -1
- package/dist/workspace-bootstrap.js.map +1 -1
- package/package.json +3 -3
- package/vendor/workspace-mcp/context.d.ts +3 -1
- package/vendor/workspace-mcp/context.js +134 -21
- package/vendor/workspace-mcp/context.js.map +1 -1
- package/vendor/workspace-mcp/types.d.ts +72 -7
- package/vendor/workspace-mcp/types.js +8 -4
- package/vendor/workspace-mcp/types.js.map +1 -1
- package/assets/templates/fastapi-sidecar/poetry.lock +0 -757
- package/assets/templates/next-tenant-app/package-lock.json +0 -9682
- package/assets/templates/next-tenant-app/pnpm-lock.yaml +0 -6062
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { appRoutes } from "@/lib/app-routes";
|
|
4
|
+
|
|
5
|
+
describe("appRoutes", () => {
|
|
6
|
+
it("builds a blog route from a single slug segment", () => {
|
|
7
|
+
expect(appRoutes.blogPost("launching-the-combined-web-starter")).toBe(
|
|
8
|
+
"/blog/launching-the-combined-web-starter",
|
|
9
|
+
);
|
|
10
|
+
expect(appRoutes.blogPost(["launching-the-combined-web-starter"])).toBe(
|
|
11
|
+
"/blog/launching-the-combined-web-starter",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects multi-segment blog slugs instead of truncating them", () => {
|
|
16
|
+
expect(() => appRoutes.blogPost(["release", "v1"])).toThrow(
|
|
17
|
+
"Blog routes require exactly one slug segment.",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("builds branded workspace shell routes", () => {
|
|
22
|
+
expect(appRoutes.workspaceConnect("fleet-alpha")).toBe("/w/fleet-alpha/connect");
|
|
23
|
+
expect(appRoutes.workspaceShell(["fleet alpha"])).toBe("/w/fleet%20alpha");
|
|
24
|
+
expect(appRoutes.loginForWorkspace("fleet-alpha")).toBe(
|
|
25
|
+
"/login?return_to=%2Fw%2Ffleet-alpha",
|
|
26
|
+
);
|
|
27
|
+
expect(appRoutes.loginForWorkspaceConnect("fleet-alpha")).toBe(
|
|
28
|
+
"/login?return_to=%2Fw%2Ffleet-alpha%2Fconnect",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects ambiguous workspace route segments", () => {
|
|
33
|
+
expect(() => appRoutes.workspaceShell(["fleet", "alpha"])).toThrow(
|
|
34
|
+
"Workspace routes require exactly one slug segment.",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Route } from "next";
|
|
2
|
+
|
|
3
|
+
function joinRouteSegments(segments: readonly string[]) {
|
|
4
|
+
return segments.map((segment) => encodeURIComponent(segment)).join("/");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function requireSingleRouteSegment(
|
|
8
|
+
slug: string | readonly string[],
|
|
9
|
+
routeLabel: string,
|
|
10
|
+
) {
|
|
11
|
+
if (typeof slug === "string") {
|
|
12
|
+
const segment = slug.trim();
|
|
13
|
+
|
|
14
|
+
if (segment.length === 0) {
|
|
15
|
+
throw new Error(`${routeLabel} require a non-empty slug segment.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return segment;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (slug.length !== 1) {
|
|
22
|
+
throw new Error(`${routeLabel} require exactly one slug segment.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [segment] = slug;
|
|
26
|
+
const normalizedSegment = segment?.trim();
|
|
27
|
+
|
|
28
|
+
if (!normalizedSegment) {
|
|
29
|
+
throw new Error(`${routeLabel} require a non-empty slug segment.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return normalizedSegment;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const publicHomeRoute: Route = "/";
|
|
36
|
+
const pricingRoute: Route = "/pricing";
|
|
37
|
+
const docsIndexRoute: Route = "/docs";
|
|
38
|
+
const blogIndexRoute: Route = "/blog";
|
|
39
|
+
const loginRoute: Route = "/login";
|
|
40
|
+
const appHomeRoute: Route = "/app";
|
|
41
|
+
const demoRoute = "/app/demo" as Route;
|
|
42
|
+
|
|
43
|
+
function loginWithReturnTo(returnTo: Route) {
|
|
44
|
+
const params = new URLSearchParams({ return_to: returnTo });
|
|
45
|
+
return `${loginRoute}?${params.toString()}` as Route;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function workspaceShellRoute(workspaceSlug: string | readonly string[]) {
|
|
49
|
+
return `/w/${encodeURIComponent(
|
|
50
|
+
requireSingleRouteSegment(workspaceSlug, "Workspace routes"),
|
|
51
|
+
)}` as Route;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function workspaceConnectRoute(workspaceSlug: string | readonly string[]) {
|
|
55
|
+
return `${workspaceShellRoute(workspaceSlug)}/connect` as Route;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const appRoutes = {
|
|
59
|
+
publicHome: publicHomeRoute,
|
|
60
|
+
pricing: pricingRoute,
|
|
61
|
+
docsIndex: docsIndexRoute,
|
|
62
|
+
docsPage(slugParts: readonly string[]) {
|
|
63
|
+
return `/docs/${joinRouteSegments(slugParts)}` as Route;
|
|
64
|
+
},
|
|
65
|
+
blogIndex: blogIndexRoute,
|
|
66
|
+
blogPost(slug: string | readonly string[]) {
|
|
67
|
+
return `/blog/${encodeURIComponent(
|
|
68
|
+
requireSingleRouteSegment(slug, "Blog routes"),
|
|
69
|
+
)}` as Route;
|
|
70
|
+
},
|
|
71
|
+
login: loginRoute,
|
|
72
|
+
loginForWorkspace(workspaceSlug: string | readonly string[]) {
|
|
73
|
+
return loginWithReturnTo(workspaceShellRoute(workspaceSlug));
|
|
74
|
+
},
|
|
75
|
+
loginForWorkspaceConnect(workspaceSlug: string | readonly string[]) {
|
|
76
|
+
return loginWithReturnTo(workspaceConnectRoute(workspaceSlug));
|
|
77
|
+
},
|
|
78
|
+
appHome: appHomeRoute,
|
|
79
|
+
demo: demoRoute,
|
|
80
|
+
workspaceShell(workspaceSlug: string | readonly string[]) {
|
|
81
|
+
return workspaceShellRoute(workspaceSlug);
|
|
82
|
+
},
|
|
83
|
+
workspaceConnect(workspaceSlug: string | readonly string[]) {
|
|
84
|
+
return workspaceConnectRoute(workspaceSlug);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildCentralSsoLoginUrl, buildShellAbsoluteUrl } from "@/lib/auth-routes.server";
|
|
4
|
+
|
|
5
|
+
describe("auth route helpers", () => {
|
|
6
|
+
it("builds an SSO login URL that returns to the requested workspace path", () => {
|
|
7
|
+
expect(buildCentralSsoLoginUrl("/w/fleet-alpha/connect", "state-1")).toBe(
|
|
8
|
+
"http://127.0.0.1:3400/login?returnTo=http%3A%2F%2F127.0.0.1%3A3301%2Fw%2Ffleet-alpha%2Fconnect%3Fmw_shell_state%3Dstate-1",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("normalizes bare workspace returns to the connect route for shell handoff", () => {
|
|
13
|
+
expect(buildCentralSsoLoginUrl("/w/fleet-alpha", "state-1")).toBe(
|
|
14
|
+
"http://127.0.0.1:3400/login?returnTo=http%3A%2F%2F127.0.0.1%3A3301%2Fw%2Ffleet-alpha%2Fconnect%3Fmw_shell_state%3Dstate-1",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to /app for unsafe return paths", () => {
|
|
19
|
+
expect(buildShellAbsoluteUrl("https://evil.example.com")).toBe(
|
|
20
|
+
"http://127.0.0.1:3301/app",
|
|
21
|
+
);
|
|
22
|
+
expect(buildShellAbsoluteUrl("//evil.example.com")).toBe(
|
|
23
|
+
"http://127.0.0.1:3301/app",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import type { Route } from "next";
|
|
4
|
+
|
|
5
|
+
import { appRoutes } from "@/lib/app-routes";
|
|
6
|
+
import { getEnv } from "@/lib/platform/env.server";
|
|
7
|
+
|
|
8
|
+
const SHELL_HANDOFF_STATE_PARAM = "mw_shell_state";
|
|
9
|
+
|
|
10
|
+
function normalizeShellReturnPath(value?: string | null): Route {
|
|
11
|
+
const candidate = String(value ?? "").trim();
|
|
12
|
+
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
|
|
13
|
+
return appRoutes.appHome;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(candidate, "http://localhost");
|
|
18
|
+
if (parsed.origin !== "http://localhost" || !parsed.pathname.startsWith("/")) {
|
|
19
|
+
return appRoutes.appHome;
|
|
20
|
+
}
|
|
21
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
22
|
+
if (segments.length === 2 && segments[0] === "w") {
|
|
23
|
+
const workspaceSlug = decodeURIComponent(segments[1] ?? "");
|
|
24
|
+
return `${appRoutes.workspaceConnect(workspaceSlug)}${parsed.search}${parsed.hash}` as Route;
|
|
25
|
+
}
|
|
26
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}` as Route;
|
|
27
|
+
} catch {
|
|
28
|
+
return appRoutes.appHome;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildShellAbsoluteUrl(
|
|
33
|
+
path: string | null | undefined,
|
|
34
|
+
handoffState?: string | null,
|
|
35
|
+
) {
|
|
36
|
+
const { MW_PUBLIC_BASE_URL } = getEnv();
|
|
37
|
+
const url = new URL(normalizeShellReturnPath(path), MW_PUBLIC_BASE_URL);
|
|
38
|
+
const normalizedState = String(handoffState ?? "").trim();
|
|
39
|
+
if (normalizedState) {
|
|
40
|
+
url.searchParams.set(SHELL_HANDOFF_STATE_PARAM, normalizedState);
|
|
41
|
+
}
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildCentralSsoLoginUrl(
|
|
46
|
+
returnPath?: string | null,
|
|
47
|
+
handoffState?: string | null,
|
|
48
|
+
) {
|
|
49
|
+
const { MW_AUTH_BASE_URL } = getEnv();
|
|
50
|
+
const url = new URL("/login", MW_AUTH_BASE_URL);
|
|
51
|
+
url.searchParams.set("returnTo", buildShellAbsoluteUrl(returnPath, handoffState));
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { hasTrustedBrowserOrigin } from "@/lib/http/same-origin";
|
|
4
|
+
|
|
5
|
+
describe("hasTrustedBrowserOrigin", () => {
|
|
6
|
+
it("accepts a same-origin browser POST", () => {
|
|
7
|
+
const request = new Request("https://shell.example.com/api/auth/logout", {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { origin: "https://shell.example.com" },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(hasTrustedBrowserOrigin(request)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects a cross-origin browser POST", () => {
|
|
16
|
+
const request = new Request("https://shell.example.com/api/auth/logout", {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { origin: "https://evil.example.com" },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(hasTrustedBrowserOrigin(request)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function hasTrustedBrowserOrigin(request: Request) {
|
|
2
|
+
const requestOrigin = new URL(request.url).origin;
|
|
3
|
+
const origin = request.headers.get("origin");
|
|
4
|
+
if (origin) {
|
|
5
|
+
return origin === requestOrigin;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const referer = request.headers.get("referer");
|
|
9
|
+
if (referer) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(referer).origin === requestOrigin;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
18
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
exchangeVuilderShellSessionHandoffCode,
|
|
5
|
+
loadCurrentSession,
|
|
6
|
+
loadCurrentWorkspaceShellSession,
|
|
7
|
+
loadWorkspaceShellSession,
|
|
8
|
+
revokeVuilderShellSession,
|
|
9
|
+
} from "@/lib/platform/client.server";
|
|
10
|
+
|
|
11
|
+
function shellContext(overrides: Record<string, unknown> = {}) {
|
|
12
|
+
return {
|
|
13
|
+
allowed_entry_visibilities: ["client_safe", "member_only", "system"],
|
|
14
|
+
affordances: {
|
|
15
|
+
workspace_shell_open: true,
|
|
16
|
+
},
|
|
17
|
+
capabilities: {
|
|
18
|
+
channels: {
|
|
19
|
+
view: true,
|
|
20
|
+
},
|
|
21
|
+
threads: {
|
|
22
|
+
post: true,
|
|
23
|
+
view: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
principal_kind: "tenant_member",
|
|
27
|
+
property_key: "",
|
|
28
|
+
role_slug: "member",
|
|
29
|
+
role_slugs: ["member"],
|
|
30
|
+
shadow_identity_ref: "",
|
|
31
|
+
stored_permissions: ["shell.channels.view", "shell.threads.view"],
|
|
32
|
+
tenant_id: "tenant-1",
|
|
33
|
+
thread_id: "",
|
|
34
|
+
user: {
|
|
35
|
+
avatar_url: "",
|
|
36
|
+
display_name: "Fleet Owner",
|
|
37
|
+
email: "owner@example.com",
|
|
38
|
+
id: "user-1",
|
|
39
|
+
username: "owner@example.com",
|
|
40
|
+
},
|
|
41
|
+
workspace_name: "Fleet Alpha",
|
|
42
|
+
workspace_slug: "fleet-alpha",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("vuilder-shell platform client", () => {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.unstubAllGlobals();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("loads shell context with the bounded shell-token header only", async () => {
|
|
53
|
+
const fetchMock = vi.fn<typeof fetch>(async () => {
|
|
54
|
+
return new Response(JSON.stringify(shellContext()), {
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
status: 200,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
60
|
+
|
|
61
|
+
const result = await loadWorkspaceShellSession(
|
|
62
|
+
{ shellSessionToken: "shell-token-1" },
|
|
63
|
+
"fleet-alpha",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result.data.session.authenticated).toBe(true);
|
|
67
|
+
expect(result.data.membership?.workspace_slug).toBe("fleet-alpha");
|
|
68
|
+
expect(result.data.workspaceCanView).toBe(true);
|
|
69
|
+
expect(result.data.workspaceCanOpen).toBe(true);
|
|
70
|
+
const firstCall = fetchMock.mock.calls[0] as
|
|
71
|
+
| [RequestInfo | URL, RequestInit?]
|
|
72
|
+
| undefined;
|
|
73
|
+
expect(firstCall).toBeDefined();
|
|
74
|
+
const headers = firstCall?.[1]?.headers as Headers;
|
|
75
|
+
expect(headers.get("X-Vuilder-Shell-Session")).toBe("shell-token-1");
|
|
76
|
+
expect(headers.get("Cookie")).toBeNull();
|
|
77
|
+
expect(headers.get("X-CSRFToken")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not call platform when no shell token is present", async () => {
|
|
81
|
+
const fetchMock = vi.fn();
|
|
82
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
83
|
+
|
|
84
|
+
const result = await loadCurrentSession({ shellSessionToken: null });
|
|
85
|
+
|
|
86
|
+
expect(result.data).toEqual({ authenticated: false });
|
|
87
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("exchanges handoff code without raw platform credentials", async () => {
|
|
91
|
+
const fetchMock = vi.fn<typeof fetch>(async () => {
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
expires_at: "2026-06-09T00:00:00Z",
|
|
95
|
+
shell_context: shellContext(),
|
|
96
|
+
shell_session_token: "shell-token-1",
|
|
97
|
+
}),
|
|
98
|
+
{
|
|
99
|
+
headers: { "content-type": "application/json" },
|
|
100
|
+
status: 200,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
105
|
+
|
|
106
|
+
const result = await exchangeVuilderShellSessionHandoffCode("code-1", "state-1");
|
|
107
|
+
|
|
108
|
+
expect(result.data.shell_session_token).toBe("shell-token-1");
|
|
109
|
+
const firstCall = fetchMock.mock.calls[0] as
|
|
110
|
+
| [RequestInfo | URL, RequestInit?]
|
|
111
|
+
| undefined;
|
|
112
|
+
expect(firstCall).toBeDefined();
|
|
113
|
+
const headers = firstCall?.[1]?.headers as Headers;
|
|
114
|
+
expect(headers.get("Cookie")).toBeNull();
|
|
115
|
+
expect(headers.get("X-CSRFToken")).toBeNull();
|
|
116
|
+
expect(headers.get("X-Vuilder-Shell-Session")).toBeNull();
|
|
117
|
+
expect(firstCall?.[1]?.body).toBe(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
shell_session_handoff_code: "code-1",
|
|
120
|
+
shell_state: "state-1",
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("revokes a shell session through the bounded shell-token header", async () => {
|
|
126
|
+
const fetchMock = vi.fn<typeof fetch>(async () => {
|
|
127
|
+
return new Response(null, { status: 204 });
|
|
128
|
+
});
|
|
129
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
130
|
+
|
|
131
|
+
await revokeVuilderShellSession({ shellSessionToken: "shell-token-1" });
|
|
132
|
+
|
|
133
|
+
const firstCall = fetchMock.mock.calls[0] as
|
|
134
|
+
| [RequestInfo | URL, RequestInit?]
|
|
135
|
+
| undefined;
|
|
136
|
+
expect(firstCall).toBeDefined();
|
|
137
|
+
const headers = firstCall?.[1]?.headers as Headers;
|
|
138
|
+
expect(firstCall?.[1]?.method).toBe("POST");
|
|
139
|
+
expect(headers.get("X-Vuilder-Shell-Session")).toBe("shell-token-1");
|
|
140
|
+
expect(headers.get("Cookie")).toBeNull();
|
|
141
|
+
expect(headers.get("X-CSRFToken")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("keeps authenticated context but fails workspace view when capabilities are missing", async () => {
|
|
145
|
+
const fetchMock = vi.fn(async () => {
|
|
146
|
+
return new Response(
|
|
147
|
+
JSON.stringify(
|
|
148
|
+
shellContext({
|
|
149
|
+
capabilities: {
|
|
150
|
+
channels: { view: true },
|
|
151
|
+
threads: { post: false, view: false },
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
{
|
|
156
|
+
headers: { "content-type": "application/json" },
|
|
157
|
+
status: 200,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
162
|
+
|
|
163
|
+
const result = await loadWorkspaceShellSession(
|
|
164
|
+
{ shellSessionToken: "shell-token-1" },
|
|
165
|
+
"fleet-alpha",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(result.data.session.authenticated).toBe(true);
|
|
169
|
+
expect(result.data.membership?.workspace_slug).toBe("fleet-alpha");
|
|
170
|
+
expect(result.data.workspaceCanView).toBe(false);
|
|
171
|
+
expect(result.data.workspaceCanOpen).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("loads the current shell workspace and preserves pending-open status", async () => {
|
|
175
|
+
const fetchMock = vi.fn(async () => {
|
|
176
|
+
return new Response(
|
|
177
|
+
JSON.stringify(
|
|
178
|
+
shellContext({
|
|
179
|
+
affordances: {
|
|
180
|
+
workspace_shell_open: false,
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
{
|
|
185
|
+
headers: { "content-type": "application/json" },
|
|
186
|
+
status: 200,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
191
|
+
|
|
192
|
+
const result = await loadCurrentWorkspaceShellSession({
|
|
193
|
+
shellSessionToken: "shell-token-1",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result.data.session.authenticated).toBe(true);
|
|
197
|
+
expect(result.data.membership?.workspace_slug).toBe("fleet-alpha");
|
|
198
|
+
expect(result.data.workspaceCanView).toBe(true);
|
|
199
|
+
expect(result.data.workspaceCanOpen).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|