minutework 0.1.40 → 0.1.41

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 (162) hide show
  1. package/EXTERNAL_ALPHA.md +17 -1
  2. package/README.md +21 -1
  3. package/assets/claude-local/skills/README.md +5 -0
  4. package/assets/claude-local/skills/app-pack-authoring/SKILL.md +15 -0
  5. package/assets/claude-local/skills/generated-workspace-architecture/SKILL.md +25 -9
  6. package/assets/claude-local/skills/shell-architecture/SKILL.md +15 -0
  7. package/assets/claude-local/skills/vuilder-public-site-authoring/SKILL.md +10 -4
  8. package/assets/claude-local/skills/vuilder-workspace-architecture/SKILL.md +78 -0
  9. package/assets/templates/vuilder-public-site/.env.example +11 -0
  10. package/assets/templates/vuilder-public-site/README.md +15 -0
  11. package/assets/templates/vuilder-public-site/eslint.config.mjs +6 -0
  12. package/assets/templates/vuilder-public-site/next-env.d.ts +4 -0
  13. package/assets/templates/vuilder-public-site/next.config.mjs +28 -0
  14. package/assets/templates/vuilder-public-site/package.json +39 -0
  15. package/assets/templates/vuilder-public-site/postcss.config.mjs +5 -0
  16. package/assets/templates/vuilder-public-site/src/app/api/onboarding/start/route.ts +19 -0
  17. package/assets/templates/vuilder-public-site/src/app/blog/[slug]/page.tsx +25 -0
  18. package/assets/templates/vuilder-public-site/src/app/blog/page.tsx +26 -0
  19. package/assets/templates/vuilder-public-site/src/app/globals.css +103 -0
  20. package/assets/templates/vuilder-public-site/src/app/layout.tsx +18 -0
  21. package/assets/templates/vuilder-public-site/src/app/onboarding/[[...step]]/page.tsx +39 -0
  22. package/assets/templates/vuilder-public-site/src/app/page.tsx +43 -0
  23. package/assets/templates/vuilder-public-site/src/lib/env.server.ts +31 -0
  24. package/assets/templates/vuilder-public-site/src/lib/platform.server.ts +47 -0
  25. package/assets/templates/vuilder-public-site/src/lib/public-dj.server.ts +86 -0
  26. package/assets/templates/vuilder-public-site/src/lib/public-dj.test.ts +8 -0
  27. package/assets/templates/vuilder-public-site/src/lib/routes.test.ts +13 -0
  28. package/assets/templates/vuilder-public-site/src/lib/routes.ts +12 -0
  29. package/assets/templates/vuilder-public-site/template.json +21 -0
  30. package/assets/templates/vuilder-public-site/tools/template/validate-template.mjs +44 -0
  31. package/assets/templates/vuilder-public-site/tsconfig.json +23 -0
  32. package/assets/templates/vuilder-public-site/vitest.config.ts +13 -0
  33. package/assets/templates/vuilder-shell/.env.example +8 -0
  34. package/assets/templates/vuilder-shell/.storybook/main.ts +19 -0
  35. package/assets/templates/vuilder-shell/.storybook/preview.tsx +38 -0
  36. package/assets/templates/vuilder-shell/README.md +49 -0
  37. package/assets/templates/vuilder-shell/components.json +21 -0
  38. package/assets/templates/vuilder-shell/eslint.config.mjs +41 -0
  39. package/assets/templates/vuilder-shell/next-env.d.ts +6 -0
  40. package/assets/templates/vuilder-shell/next.config.mjs +33 -0
  41. package/assets/templates/vuilder-shell/package.json +61 -0
  42. package/assets/templates/vuilder-shell/postcss.config.mjs +8 -0
  43. package/assets/templates/vuilder-shell/public/.gitkeep +1 -0
  44. package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.test.ts +105 -0
  45. package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.ts +63 -0
  46. package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.test.ts +63 -0
  47. package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.ts +24 -0
  48. package/assets/templates/vuilder-shell/src/app/api/auth/session/route.test.ts +70 -0
  49. package/assets/templates/vuilder-shell/src/app/api/auth/session/route.ts +27 -0
  50. package/assets/templates/vuilder-shell/src/app/app/layout.tsx +17 -0
  51. package/assets/templates/vuilder-shell/src/app/app/page.tsx +30 -0
  52. package/assets/templates/vuilder-shell/src/app/blog/[slug]/page.tsx +15 -0
  53. package/assets/templates/vuilder-shell/src/app/blog/page.tsx +15 -0
  54. package/assets/templates/vuilder-shell/src/app/docs/[...slug]/page.tsx +15 -0
  55. package/assets/templates/vuilder-shell/src/app/docs/page.tsx +15 -0
  56. package/assets/templates/vuilder-shell/src/app/globals.css +70 -0
  57. package/assets/templates/vuilder-shell/src/app/layout.tsx +69 -0
  58. package/assets/templates/vuilder-shell/src/app/login/route.test.ts +33 -0
  59. package/assets/templates/vuilder-shell/src/app/login/route.ts +21 -0
  60. package/assets/templates/vuilder-shell/src/app/page.tsx +16 -0
  61. package/assets/templates/vuilder-shell/src/app/pricing/page.tsx +15 -0
  62. package/assets/templates/vuilder-shell/src/app/providers.tsx +25 -0
  63. package/assets/templates/vuilder-shell/src/app/robots.ts +21 -0
  64. package/assets/templates/vuilder-shell/src/app/sitemap.ts +33 -0
  65. package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/connect/page.tsx +31 -0
  66. package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/page.tsx +54 -0
  67. package/assets/templates/vuilder-shell/src/components/ui/button.tsx +59 -0
  68. package/assets/templates/vuilder-shell/src/components/ui/input.tsx +21 -0
  69. package/assets/templates/vuilder-shell/src/design-system/docs/governance.mdx +26 -0
  70. package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.stories.tsx +48 -0
  71. package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.tsx +26 -0
  72. package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.stories.tsx +26 -0
  73. package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.tsx +35 -0
  74. package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
  75. package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
  76. package/assets/templates/vuilder-shell/src/design-system/primitives/button.stories.tsx +37 -0
  77. package/assets/templates/vuilder-shell/src/design-system/primitives/button.ts +1 -0
  78. package/assets/templates/vuilder-shell/src/design-system/primitives/input.stories.tsx +26 -0
  79. package/assets/templates/vuilder-shell/src/design-system/primitives/input.ts +1 -0
  80. package/assets/templates/vuilder-shell/src/design-system/recipes/chrome.ts +28 -0
  81. package/assets/templates/vuilder-shell/src/design-system/tokens/foundation.css +31 -0
  82. package/assets/templates/vuilder-shell/src/design-system/tokens/index.css +3 -0
  83. package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.json +85 -0
  84. package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.ts +87 -0
  85. package/assets/templates/vuilder-shell/src/design-system/tokens/semantic.css +105 -0
  86. package/assets/templates/vuilder-shell/src/design-system/tokens/theme.css +59 -0
  87. package/assets/templates/vuilder-shell/src/design-system/tokens/tokens.stories.tsx +71 -0
  88. package/assets/templates/vuilder-shell/src/features/dashboard/components/tenant-dashboard.tsx +134 -0
  89. package/assets/templates/vuilder-shell/src/features/public-shell/components/static-public-page.tsx +58 -0
  90. package/assets/templates/vuilder-shell/src/features/shell/components/authenticated-app-layout-shell.tsx +84 -0
  91. package/assets/templates/vuilder-shell/src/features/shell/components/private-app-shell.tsx +22 -0
  92. package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-connect-screen.tsx +89 -0
  93. package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-workspace-screen.tsx +49 -0
  94. package/assets/templates/vuilder-shell/src/lib/app-routes.test.ts +37 -0
  95. package/assets/templates/vuilder-shell/src/lib/app-routes.ts +86 -0
  96. package/assets/templates/vuilder-shell/src/lib/auth-routes.server.test.ts +26 -0
  97. package/assets/templates/vuilder-shell/src/lib/auth-routes.server.ts +53 -0
  98. package/assets/templates/vuilder-shell/src/lib/http/same-origin.test.ts +23 -0
  99. package/assets/templates/vuilder-shell/src/lib/http/same-origin.ts +18 -0
  100. package/assets/templates/vuilder-shell/src/lib/platform/client.server.test.ts +201 -0
  101. package/assets/templates/vuilder-shell/src/lib/platform/client.server.ts +540 -0
  102. package/assets/templates/vuilder-shell/src/lib/platform/contracts.ts +190 -0
  103. package/assets/templates/vuilder-shell/src/lib/platform/endpoints.server.ts +29 -0
  104. package/assets/templates/vuilder-shell/src/lib/platform/env.server.ts +82 -0
  105. package/assets/templates/vuilder-shell/src/lib/platform/route-response.ts +33 -0
  106. package/assets/templates/vuilder-shell/src/lib/platform/session.server.ts +145 -0
  107. package/assets/templates/vuilder-shell/src/lib/public-site.test.ts +20 -0
  108. package/assets/templates/vuilder-shell/src/lib/public-site.ts +48 -0
  109. package/assets/templates/vuilder-shell/src/lib/theme-config.ts +10 -0
  110. package/assets/templates/vuilder-shell/src/lib/theme.tsx +159 -0
  111. package/assets/templates/vuilder-shell/src/lib/utils.ts +6 -0
  112. package/assets/templates/vuilder-shell/template.json +28 -0
  113. package/assets/templates/vuilder-shell/template.schema.json +171 -0
  114. package/assets/templates/vuilder-shell/test/server-only-stub.ts +1 -0
  115. package/assets/templates/vuilder-shell/tools/design-system/build-token-manifest.mjs +3 -0
  116. package/assets/templates/vuilder-shell/tools/design-system/check-imports.mjs +9 -0
  117. package/assets/templates/vuilder-shell/tools/design-system/check-stories.mjs +9 -0
  118. package/assets/templates/vuilder-shell/tools/design-system/check-values.mjs +9 -0
  119. package/assets/templates/vuilder-shell/tools/design-system/checks.mjs +238 -0
  120. package/assets/templates/vuilder-shell/tools/design-system/eslint-plugin-design-system.mjs +184 -0
  121. package/assets/templates/vuilder-shell/tools/design-system/playwright.config.mjs +34 -0
  122. package/assets/templates/vuilder-shell/tools/design-system/run-checks.mjs +22 -0
  123. package/assets/templates/vuilder-shell/tools/design-system/shared.mjs +166 -0
  124. package/assets/templates/vuilder-shell/tools/design-system/visual.spec.ts +41 -0
  125. package/assets/templates/vuilder-shell/tools/template/validate-route-contract.mjs +373 -0
  126. package/assets/templates/vuilder-shell/tools/template/validate-template.mjs +45 -0
  127. package/assets/templates/vuilder-shell/tools/template/with-public-site-fixture.mjs +45 -0
  128. package/assets/templates/vuilder-shell/tsconfig.json +42 -0
  129. package/assets/templates/vuilder-shell/vitest.config.ts +23 -0
  130. package/dist/auth.js +66 -14
  131. package/dist/auth.js.map +1 -1
  132. package/dist/deploy-state.d.ts +1 -0
  133. package/dist/deploy-state.js.map +1 -1
  134. package/dist/deploy.js +18 -4
  135. package/dist/deploy.js.map +1 -1
  136. package/dist/developer-client.d.ts +1 -1
  137. package/dist/index.js +12 -2
  138. package/dist/index.js.map +1 -1
  139. package/dist/init-prompt.js +21 -13
  140. package/dist/init-prompt.js.map +1 -1
  141. package/dist/init.d.ts +3 -1
  142. package/dist/init.js +103 -12
  143. package/dist/init.js.map +1 -1
  144. package/dist/orchestrator-context.js +17 -5
  145. package/dist/orchestrator-context.js.map +1 -1
  146. package/dist/orchestrator-state.d.ts +2 -2
  147. package/dist/orchestrator-state.js.map +1 -1
  148. package/dist/publish.js +12 -2
  149. package/dist/publish.js.map +1 -1
  150. package/dist/state.d.ts +2 -0
  151. package/dist/state.js +9 -0
  152. package/dist/state.js.map +1 -1
  153. package/package.json +3 -3
  154. package/vendor/workspace-mcp/context.d.ts +3 -1
  155. package/vendor/workspace-mcp/context.js +134 -21
  156. package/vendor/workspace-mcp/context.js.map +1 -1
  157. package/vendor/workspace-mcp/types.d.ts +72 -7
  158. package/vendor/workspace-mcp/types.js +8 -4
  159. package/vendor/workspace-mcp/types.js.map +1 -1
  160. package/assets/templates/fastapi-sidecar/poetry.lock +0 -757
  161. package/assets/templates/next-tenant-app/package-lock.json +0 -9682
  162. package/assets/templates/next-tenant-app/pnpm-lock.yaml +0 -6062
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import { LayoutDashboard, LogOut, PlaySquare } from "lucide-react";
7
+
8
+ import { ThemeModeToggle } from "@/design-system/patterns/theme-mode-toggle";
9
+ import { Button } from "@/design-system/primitives/button";
10
+ import { appRoutes } from "@/lib/app-routes";
11
+ import type {
12
+ AuthenticatedPlatformSession,
13
+ SessionMembership,
14
+ } from "@/lib/platform/contracts";
15
+
16
+ export function AuthenticatedAppLayoutShell({
17
+ appName,
18
+ membership,
19
+ session,
20
+ children,
21
+ }: {
22
+ appName: string;
23
+ membership: SessionMembership;
24
+ session: AuthenticatedPlatformSession;
25
+ children: ReactNode;
26
+ }) {
27
+ const router = useRouter();
28
+
29
+ async function handleLogout() {
30
+ await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
31
+ router.replace(appRoutes.loginForWorkspace(membership.workspace_slug));
32
+ router.refresh();
33
+ }
34
+
35
+ return (
36
+ <main className="min-h-screen bg-background text-foreground">
37
+ <div className="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-6 py-8">
38
+ <header className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
39
+ <div className="space-y-2">
40
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
41
+ {membership.workspace_name || membership.tenant_name}
42
+ </p>
43
+ <h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
44
+ {appName}
45
+ </h1>
46
+ <p className="max-w-3xl text-base leading-7 text-muted-foreground sm:text-lg">
47
+ Signed in as {session.user?.email || session.user?.username}.
48
+ </p>
49
+ </div>
50
+
51
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
52
+ <ThemeModeToggle className="w-full sm:w-72" />
53
+ <Button
54
+ type="button"
55
+ variant="outline"
56
+ className="gap-2"
57
+ onClick={handleLogout}
58
+ >
59
+ <LogOut className="size-4" />
60
+ Log out
61
+ </Button>
62
+ </div>
63
+ </header>
64
+
65
+ <nav className="flex flex-wrap gap-2">
66
+ <Button asChild variant="default">
67
+ <Link href={appRoutes.workspaceShell(membership.workspace_slug)}>
68
+ <LayoutDashboard className="size-4" />
69
+ Dashboard
70
+ </Link>
71
+ </Button>
72
+ <Button asChild variant="outline">
73
+ <Link href={appRoutes.demo}>
74
+ <PlaySquare className="size-4" />
75
+ Demo
76
+ </Link>
77
+ </Button>
78
+ </nav>
79
+
80
+ {children}
81
+ </div>
82
+ </main>
83
+ );
84
+ }
@@ -0,0 +1,22 @@
1
+ import { TenantDashboard } from "@/features/dashboard/components/tenant-dashboard";
2
+ import { VuilderConnectScreen } from "@/features/vuilder-shell/components/vuilder-connect-screen";
3
+ import type {
4
+ PlatformSession,
5
+ SessionMembership,
6
+ } from "@/lib/platform/contracts";
7
+
8
+ export function PrivateAppShell({
9
+ appName,
10
+ membership,
11
+ session,
12
+ }: {
13
+ appName: string;
14
+ membership: SessionMembership | null;
15
+ session: PlatformSession;
16
+ }) {
17
+ if (!session.authenticated || !membership) {
18
+ return <VuilderConnectScreen session={session} workspaceSlug="workspace" />;
19
+ }
20
+
21
+ return <TenantDashboard appName={appName} membership={membership} session={session} />;
22
+ }
@@ -0,0 +1,89 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight, CheckCircle2, ServerCog } from "lucide-react";
3
+
4
+ import { PanelFrame } from "@/design-system/patterns/panel-frame";
5
+ import { StatusBadge } from "@/design-system/patterns/status-badge";
6
+ import { Button } from "@/design-system/primitives/button";
7
+ import { appRoutes } from "@/lib/app-routes";
8
+ import type { PlatformSession } from "@/lib/platform/contracts";
9
+ import { workspaceMembershipForSession } from "@/lib/platform/contracts";
10
+
11
+ export function VuilderConnectScreen({
12
+ session,
13
+ workspaceSlug,
14
+ }: {
15
+ session: PlatformSession;
16
+ workspaceSlug: string;
17
+ }) {
18
+ const membership = workspaceMembershipForSession(session, workspaceSlug);
19
+ const tenantName = membership?.workspace_name || membership?.tenant_name || workspaceSlug;
20
+ const hasWorkspaceMembership = Boolean(membership);
21
+ const isAuthenticated = session.authenticated;
22
+
23
+ return (
24
+ <main className="min-h-screen bg-background text-foreground">
25
+ <div className="mx-auto flex min-h-screen max-w-5xl items-center px-6 py-10">
26
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="w-full space-y-6">
27
+ <div className="flex items-center gap-3 text-primary">
28
+ {hasWorkspaceMembership ? (
29
+ <CheckCircle2 className="size-5" />
30
+ ) : (
31
+ <ServerCog className="size-5" />
32
+ )}
33
+ <p className="text-sm font-semibold uppercase tracking-widest">
34
+ Vuilder Shell
35
+ </p>
36
+ </div>
37
+
38
+ <div className="space-y-3">
39
+ <h1 className="text-4xl font-semibold tracking-tight text-balance">
40
+ {hasWorkspaceMembership
41
+ ? `${tenantName} is ready to open.`
42
+ : isAuthenticated
43
+ ? "Workspace access is not available."
44
+ : "Sign in to open this customer workspace."}
45
+ </h1>
46
+ <p className="max-w-2xl text-base leading-7 text-muted-foreground">
47
+ Runtime provisioning and app-pack installation are owned by the
48
+ platform onboarding intent. This shell opens the branded tenant
49
+ experience after the customer session is available.
50
+ </p>
51
+ </div>
52
+
53
+ <div className="grid gap-3 sm:grid-cols-3">
54
+ <StatusBadge tone={isAuthenticated ? "success" : "default"}>
55
+ Identity
56
+ </StatusBadge>
57
+ <StatusBadge tone="primary">Runtime</StatusBadge>
58
+ <StatusBadge tone="primary">App Pack</StatusBadge>
59
+ </div>
60
+
61
+ <div className="flex flex-col gap-3 sm:flex-row">
62
+ {hasWorkspaceMembership ? (
63
+ <Button asChild className="gap-2">
64
+ <Link href={appRoutes.workspaceShell(workspaceSlug)}>
65
+ Open Workspace
66
+ <ArrowRight className="size-4" />
67
+ </Link>
68
+ </Button>
69
+ ) : !isAuthenticated ? (
70
+ <Button asChild className="gap-2">
71
+ <Link href={appRoutes.loginForWorkspaceConnect(workspaceSlug)}>
72
+ Open Login
73
+ <ArrowRight className="size-4" />
74
+ </Link>
75
+ </Button>
76
+ ) : (
77
+ <Button asChild variant="outline" className="gap-2">
78
+ <Link href={appRoutes.appHome}>
79
+ Workspace Home
80
+ <ArrowRight className="size-4" />
81
+ </Link>
82
+ </Button>
83
+ )}
84
+ </div>
85
+ </PanelFrame>
86
+ </div>
87
+ </main>
88
+ );
89
+ }
@@ -0,0 +1,49 @@
1
+ import { Building2, Package, ServerCog } from "lucide-react";
2
+
3
+ import { PanelFrame } from "@/design-system/patterns/panel-frame";
4
+ import { StatusBadge } from "@/design-system/patterns/status-badge";
5
+ import { TenantDashboard } from "@/features/dashboard/components/tenant-dashboard";
6
+ import type {
7
+ AuthenticatedPlatformSession,
8
+ SessionMembership,
9
+ } from "@/lib/platform/contracts";
10
+
11
+ export function VuilderWorkspaceScreen({
12
+ appName,
13
+ membership,
14
+ session,
15
+ workspaceSlug,
16
+ }: {
17
+ appName: string;
18
+ membership: SessionMembership;
19
+ session: AuthenticatedPlatformSession;
20
+ workspaceSlug: string;
21
+ }) {
22
+ return (
23
+ <div className="grid gap-6">
24
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-4">
25
+ <div className="flex flex-wrap gap-3">
26
+ <StatusBadge tone="primary">
27
+ <Building2 className="mr-1 size-3" />
28
+ {membership.workspace_slug || workspaceSlug}
29
+ </StatusBadge>
30
+ <StatusBadge tone="primary">
31
+ <ServerCog className="mr-1 size-3" />
32
+ {membership.runtime_status || "Runtime status"}
33
+ </StatusBadge>
34
+ <StatusBadge tone="primary">
35
+ <Package className="mr-1 size-3" />
36
+ App pack status
37
+ </StatusBadge>
38
+ </div>
39
+ <p className="max-w-3xl text-sm leading-7 text-muted-foreground">
40
+ This branded shell renders the tenant experience for the active
41
+ customer session. Identity, provisioning, and install authority remain
42
+ on the MinuteWork platform path.
43
+ </p>
44
+ </PanelFrame>
45
+
46
+ <TenantDashboard appName={appName} membership={membership} session={session} />
47
+ </div>
48
+ );
49
+ }
@@ -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
+ }