minutework 0.1.39 → 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 (166) hide show
  1. package/EXTERNAL_ALPHA.md +17 -1
  2. package/README.md +21 -1
  3. package/assets/claude-local/skills/README.md +6 -0
  4. package/assets/claude-local/skills/ai-capability-defaults/SKILL.md +3 -0
  5. package/assets/claude-local/skills/app-pack-authoring/SKILL.md +15 -0
  6. package/assets/claude-local/skills/generated-workspace-architecture/SKILL.md +25 -9
  7. package/assets/claude-local/skills/integration-broker-and-connectors/SKILL.md +122 -0
  8. package/assets/claude-local/skills/layering-and-import-modes/SKILL.md +4 -0
  9. package/assets/claude-local/skills/project-overview-and-strategy/SKILL.md +6 -0
  10. package/assets/claude-local/skills/shell-architecture/SKILL.md +15 -0
  11. package/assets/claude-local/skills/vuilder-public-site-authoring/SKILL.md +10 -4
  12. package/assets/claude-local/skills/vuilder-workspace-architecture/SKILL.md +78 -0
  13. package/assets/templates/vuilder-public-site/.env.example +11 -0
  14. package/assets/templates/vuilder-public-site/README.md +15 -0
  15. package/assets/templates/vuilder-public-site/eslint.config.mjs +6 -0
  16. package/assets/templates/vuilder-public-site/next-env.d.ts +4 -0
  17. package/assets/templates/vuilder-public-site/next.config.mjs +28 -0
  18. package/assets/templates/vuilder-public-site/package.json +39 -0
  19. package/assets/templates/vuilder-public-site/postcss.config.mjs +5 -0
  20. package/assets/templates/vuilder-public-site/src/app/api/onboarding/start/route.ts +19 -0
  21. package/assets/templates/vuilder-public-site/src/app/blog/[slug]/page.tsx +25 -0
  22. package/assets/templates/vuilder-public-site/src/app/blog/page.tsx +26 -0
  23. package/assets/templates/vuilder-public-site/src/app/globals.css +103 -0
  24. package/assets/templates/vuilder-public-site/src/app/layout.tsx +18 -0
  25. package/assets/templates/vuilder-public-site/src/app/onboarding/[[...step]]/page.tsx +39 -0
  26. package/assets/templates/vuilder-public-site/src/app/page.tsx +43 -0
  27. package/assets/templates/vuilder-public-site/src/lib/env.server.ts +31 -0
  28. package/assets/templates/vuilder-public-site/src/lib/platform.server.ts +47 -0
  29. package/assets/templates/vuilder-public-site/src/lib/public-dj.server.ts +86 -0
  30. package/assets/templates/vuilder-public-site/src/lib/public-dj.test.ts +8 -0
  31. package/assets/templates/vuilder-public-site/src/lib/routes.test.ts +13 -0
  32. package/assets/templates/vuilder-public-site/src/lib/routes.ts +12 -0
  33. package/assets/templates/vuilder-public-site/template.json +21 -0
  34. package/assets/templates/vuilder-public-site/tools/template/validate-template.mjs +44 -0
  35. package/assets/templates/vuilder-public-site/tsconfig.json +23 -0
  36. package/assets/templates/vuilder-public-site/vitest.config.ts +13 -0
  37. package/assets/templates/vuilder-shell/.env.example +8 -0
  38. package/assets/templates/vuilder-shell/.storybook/main.ts +19 -0
  39. package/assets/templates/vuilder-shell/.storybook/preview.tsx +38 -0
  40. package/assets/templates/vuilder-shell/README.md +49 -0
  41. package/assets/templates/vuilder-shell/components.json +21 -0
  42. package/assets/templates/vuilder-shell/eslint.config.mjs +41 -0
  43. package/assets/templates/vuilder-shell/next-env.d.ts +6 -0
  44. package/assets/templates/vuilder-shell/next.config.mjs +33 -0
  45. package/assets/templates/vuilder-shell/package.json +61 -0
  46. package/assets/templates/vuilder-shell/postcss.config.mjs +8 -0
  47. package/assets/templates/vuilder-shell/public/.gitkeep +1 -0
  48. package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.test.ts +105 -0
  49. package/assets/templates/vuilder-shell/src/app/api/auth/accept-shell-session/route.ts +63 -0
  50. package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.test.ts +63 -0
  51. package/assets/templates/vuilder-shell/src/app/api/auth/logout/route.ts +24 -0
  52. package/assets/templates/vuilder-shell/src/app/api/auth/session/route.test.ts +70 -0
  53. package/assets/templates/vuilder-shell/src/app/api/auth/session/route.ts +27 -0
  54. package/assets/templates/vuilder-shell/src/app/app/layout.tsx +17 -0
  55. package/assets/templates/vuilder-shell/src/app/app/page.tsx +30 -0
  56. package/assets/templates/vuilder-shell/src/app/blog/[slug]/page.tsx +15 -0
  57. package/assets/templates/vuilder-shell/src/app/blog/page.tsx +15 -0
  58. package/assets/templates/vuilder-shell/src/app/docs/[...slug]/page.tsx +15 -0
  59. package/assets/templates/vuilder-shell/src/app/docs/page.tsx +15 -0
  60. package/assets/templates/vuilder-shell/src/app/globals.css +70 -0
  61. package/assets/templates/vuilder-shell/src/app/layout.tsx +69 -0
  62. package/assets/templates/vuilder-shell/src/app/login/route.test.ts +33 -0
  63. package/assets/templates/vuilder-shell/src/app/login/route.ts +21 -0
  64. package/assets/templates/vuilder-shell/src/app/page.tsx +16 -0
  65. package/assets/templates/vuilder-shell/src/app/pricing/page.tsx +15 -0
  66. package/assets/templates/vuilder-shell/src/app/providers.tsx +25 -0
  67. package/assets/templates/vuilder-shell/src/app/robots.ts +21 -0
  68. package/assets/templates/vuilder-shell/src/app/sitemap.ts +33 -0
  69. package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/connect/page.tsx +31 -0
  70. package/assets/templates/vuilder-shell/src/app/w/[workspace_slug]/page.tsx +54 -0
  71. package/assets/templates/vuilder-shell/src/components/ui/button.tsx +59 -0
  72. package/assets/templates/vuilder-shell/src/components/ui/input.tsx +21 -0
  73. package/assets/templates/vuilder-shell/src/design-system/docs/governance.mdx +26 -0
  74. package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.stories.tsx +48 -0
  75. package/assets/templates/vuilder-shell/src/design-system/patterns/panel-frame.tsx +26 -0
  76. package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.stories.tsx +26 -0
  77. package/assets/templates/vuilder-shell/src/design-system/patterns/status-badge.tsx +35 -0
  78. package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.stories.tsx +21 -0
  79. package/assets/templates/vuilder-shell/src/design-system/patterns/theme-mode-toggle.tsx +75 -0
  80. package/assets/templates/vuilder-shell/src/design-system/primitives/button.stories.tsx +37 -0
  81. package/assets/templates/vuilder-shell/src/design-system/primitives/button.ts +1 -0
  82. package/assets/templates/vuilder-shell/src/design-system/primitives/input.stories.tsx +26 -0
  83. package/assets/templates/vuilder-shell/src/design-system/primitives/input.ts +1 -0
  84. package/assets/templates/vuilder-shell/src/design-system/recipes/chrome.ts +28 -0
  85. package/assets/templates/vuilder-shell/src/design-system/tokens/foundation.css +31 -0
  86. package/assets/templates/vuilder-shell/src/design-system/tokens/index.css +3 -0
  87. package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.json +85 -0
  88. package/assets/templates/vuilder-shell/src/design-system/tokens/manifest.ts +87 -0
  89. package/assets/templates/vuilder-shell/src/design-system/tokens/semantic.css +105 -0
  90. package/assets/templates/vuilder-shell/src/design-system/tokens/theme.css +59 -0
  91. package/assets/templates/vuilder-shell/src/design-system/tokens/tokens.stories.tsx +71 -0
  92. package/assets/templates/vuilder-shell/src/features/dashboard/components/tenant-dashboard.tsx +134 -0
  93. package/assets/templates/vuilder-shell/src/features/public-shell/components/static-public-page.tsx +58 -0
  94. package/assets/templates/vuilder-shell/src/features/shell/components/authenticated-app-layout-shell.tsx +84 -0
  95. package/assets/templates/vuilder-shell/src/features/shell/components/private-app-shell.tsx +22 -0
  96. package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-connect-screen.tsx +89 -0
  97. package/assets/templates/vuilder-shell/src/features/vuilder-shell/components/vuilder-workspace-screen.tsx +49 -0
  98. package/assets/templates/vuilder-shell/src/lib/app-routes.test.ts +37 -0
  99. package/assets/templates/vuilder-shell/src/lib/app-routes.ts +86 -0
  100. package/assets/templates/vuilder-shell/src/lib/auth-routes.server.test.ts +26 -0
  101. package/assets/templates/vuilder-shell/src/lib/auth-routes.server.ts +53 -0
  102. package/assets/templates/vuilder-shell/src/lib/http/same-origin.test.ts +23 -0
  103. package/assets/templates/vuilder-shell/src/lib/http/same-origin.ts +18 -0
  104. package/assets/templates/vuilder-shell/src/lib/platform/client.server.test.ts +201 -0
  105. package/assets/templates/vuilder-shell/src/lib/platform/client.server.ts +540 -0
  106. package/assets/templates/vuilder-shell/src/lib/platform/contracts.ts +190 -0
  107. package/assets/templates/vuilder-shell/src/lib/platform/endpoints.server.ts +29 -0
  108. package/assets/templates/vuilder-shell/src/lib/platform/env.server.ts +82 -0
  109. package/assets/templates/vuilder-shell/src/lib/platform/route-response.ts +33 -0
  110. package/assets/templates/vuilder-shell/src/lib/platform/session.server.ts +145 -0
  111. package/assets/templates/vuilder-shell/src/lib/public-site.test.ts +20 -0
  112. package/assets/templates/vuilder-shell/src/lib/public-site.ts +48 -0
  113. package/assets/templates/vuilder-shell/src/lib/theme-config.ts +10 -0
  114. package/assets/templates/vuilder-shell/src/lib/theme.tsx +159 -0
  115. package/assets/templates/vuilder-shell/src/lib/utils.ts +6 -0
  116. package/assets/templates/vuilder-shell/template.json +28 -0
  117. package/assets/templates/vuilder-shell/template.schema.json +171 -0
  118. package/assets/templates/vuilder-shell/test/server-only-stub.ts +1 -0
  119. package/assets/templates/vuilder-shell/tools/design-system/build-token-manifest.mjs +3 -0
  120. package/assets/templates/vuilder-shell/tools/design-system/check-imports.mjs +9 -0
  121. package/assets/templates/vuilder-shell/tools/design-system/check-stories.mjs +9 -0
  122. package/assets/templates/vuilder-shell/tools/design-system/check-values.mjs +9 -0
  123. package/assets/templates/vuilder-shell/tools/design-system/checks.mjs +238 -0
  124. package/assets/templates/vuilder-shell/tools/design-system/eslint-plugin-design-system.mjs +184 -0
  125. package/assets/templates/vuilder-shell/tools/design-system/playwright.config.mjs +34 -0
  126. package/assets/templates/vuilder-shell/tools/design-system/run-checks.mjs +22 -0
  127. package/assets/templates/vuilder-shell/tools/design-system/shared.mjs +166 -0
  128. package/assets/templates/vuilder-shell/tools/design-system/visual.spec.ts +41 -0
  129. package/assets/templates/vuilder-shell/tools/template/validate-route-contract.mjs +373 -0
  130. package/assets/templates/vuilder-shell/tools/template/validate-template.mjs +45 -0
  131. package/assets/templates/vuilder-shell/tools/template/with-public-site-fixture.mjs +45 -0
  132. package/assets/templates/vuilder-shell/tsconfig.json +42 -0
  133. package/assets/templates/vuilder-shell/vitest.config.ts +23 -0
  134. package/dist/auth.js +66 -14
  135. package/dist/auth.js.map +1 -1
  136. package/dist/deploy-state.d.ts +1 -0
  137. package/dist/deploy-state.js.map +1 -1
  138. package/dist/deploy.js +18 -4
  139. package/dist/deploy.js.map +1 -1
  140. package/dist/developer-client.d.ts +1 -1
  141. package/dist/index.js +12 -2
  142. package/dist/index.js.map +1 -1
  143. package/dist/init-prompt.js +21 -13
  144. package/dist/init-prompt.js.map +1 -1
  145. package/dist/init.d.ts +3 -1
  146. package/dist/init.js +103 -12
  147. package/dist/init.js.map +1 -1
  148. package/dist/orchestrator-context.js +17 -5
  149. package/dist/orchestrator-context.js.map +1 -1
  150. package/dist/orchestrator-state.d.ts +2 -2
  151. package/dist/orchestrator-state.js.map +1 -1
  152. package/dist/publish.js +12 -2
  153. package/dist/publish.js.map +1 -1
  154. package/dist/state.d.ts +2 -0
  155. package/dist/state.js +9 -0
  156. package/dist/state.js.map +1 -1
  157. package/package.json +2 -2
  158. package/vendor/workspace-mcp/context.d.ts +3 -1
  159. package/vendor/workspace-mcp/context.js +134 -21
  160. package/vendor/workspace-mcp/context.js.map +1 -1
  161. package/vendor/workspace-mcp/types.d.ts +72 -7
  162. package/vendor/workspace-mcp/types.js +8 -4
  163. package/vendor/workspace-mcp/types.js.map +1 -1
  164. package/assets/templates/fastapi-sidecar/poetry.lock +0 -757
  165. package/assets/templates/next-tenant-app/package-lock.json +0 -9682
  166. package/assets/templates/next-tenant-app/pnpm-lock.yaml +0 -6062
@@ -0,0 +1,134 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight, Database, ShieldCheck, UserRound } 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 { AuthenticatedPlatformSession } from "@/lib/platform/contracts";
9
+ import type { SessionMembership } from "@/lib/platform/contracts";
10
+ import { preferredWorkspace } from "@/lib/platform/contracts";
11
+
12
+ function DashboardMetric({
13
+ label,
14
+ value,
15
+ }: {
16
+ label: string;
17
+ value: string;
18
+ }) {
19
+ return (
20
+ <PanelFrame tone="raised" radius="xl" padding="md" className="space-y-2">
21
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
22
+ {label}
23
+ </p>
24
+ <p className="text-sm font-semibold text-foreground">{value}</p>
25
+ </PanelFrame>
26
+ );
27
+ }
28
+
29
+ export function TenantDashboard({
30
+ appName,
31
+ membership: resolvedMembership,
32
+ session,
33
+ }: {
34
+ appName: string;
35
+ membership?: SessionMembership | null;
36
+ session: AuthenticatedPlatformSession;
37
+ }) {
38
+ const membership = resolvedMembership ?? preferredWorkspace(session);
39
+
40
+ return (
41
+ <div className="grid gap-6">
42
+ <PanelFrame tone="floating" radius="xl" padding="lg" className="space-y-6">
43
+ <div className="space-y-2">
44
+ <h2 className="text-3xl font-semibold tracking-tight">
45
+ {appName} workspace
46
+ </h2>
47
+ <p className="max-w-3xl text-sm leading-7 text-muted-foreground">
48
+ Customer workspace state is loaded through the platform member
49
+ session established by central SSO.
50
+ </p>
51
+ </div>
52
+
53
+ <div className="grid gap-3 md:grid-cols-3">
54
+ <DashboardMetric
55
+ label="Customer"
56
+ value={session.user?.display_name || session.user?.email || "Customer"}
57
+ />
58
+ <DashboardMetric
59
+ label="Tenant"
60
+ value={membership?.workspace_name || membership?.tenant_name || "Workspace"}
61
+ />
62
+ <DashboardMetric
63
+ label="Role"
64
+ value={
65
+ membership?.roles.map((role) => role.name).join(", ") ||
66
+ membership?.role ||
67
+ "member"
68
+ }
69
+ />
70
+ </div>
71
+ </PanelFrame>
72
+
73
+ <section className="grid gap-6 xl:grid-cols-[1fr,1fr]">
74
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-4">
75
+ <div className="flex items-center gap-2">
76
+ <ShieldCheck className="size-5 text-primary" />
77
+ <h3 className="text-xl font-semibold tracking-tight">
78
+ Platform SSO
79
+ </h3>
80
+ </div>
81
+ <div className="flex flex-wrap gap-3">
82
+ <StatusBadge tone="primary">shell token</StatusBadge>
83
+ <StatusBadge tone="info">HttpOnly cookies</StatusBadge>
84
+ <StatusBadge tone="success">CSRF protected</StatusBadge>
85
+ </div>
86
+ <p className="text-sm leading-7 text-muted-foreground">
87
+ The shell consumes a platform member session and leaves identity,
88
+ provisioning, and install authority on platform-owned APIs.
89
+ </p>
90
+ </PanelFrame>
91
+
92
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-4">
93
+ <div className="flex items-center gap-2">
94
+ <Database className="size-5 text-primary" />
95
+ <h3 className="text-xl font-semibold tracking-tight">
96
+ Gateway surfaces
97
+ </h3>
98
+ </div>
99
+ <p className="text-sm leading-7 text-muted-foreground">
100
+ Runtime data and agent surfaces should be added through approved
101
+ app-pack and platform gateway contracts, not browser-held runtime
102
+ credentials.
103
+ </p>
104
+ <Button asChild className="w-fit">
105
+ <Link href={appRoutes.demo}>
106
+ Open demo
107
+ <ArrowRight className="size-4" />
108
+ </Link>
109
+ </Button>
110
+ </PanelFrame>
111
+ </section>
112
+
113
+ <PanelFrame tone="raised" radius="xl" padding="lg" className="space-y-4">
114
+ <div className="flex items-center gap-2">
115
+ <UserRound className="size-5 text-primary" />
116
+ <h3 className="text-xl font-semibold tracking-tight">
117
+ Session payload
118
+ </h3>
119
+ </div>
120
+ <pre className="overflow-auto rounded-md bg-muted p-4 text-xs text-muted-foreground">
121
+ {JSON.stringify(
122
+ {
123
+ principal_kind: "tenant_member",
124
+ tenant_id: membership?.tenant_id,
125
+ workspace_slug: membership?.workspace_slug,
126
+ },
127
+ null,
128
+ 2,
129
+ )}
130
+ </pre>
131
+ </PanelFrame>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,58 @@
1
+ import Link from "next/link";
2
+ import { ArrowRight } from "lucide-react";
3
+
4
+ import { Button } from "@/design-system/primitives/button";
5
+ import { appRoutes } from "@/lib/app-routes";
6
+
7
+ export function StaticPublicPage({
8
+ eyebrow,
9
+ title,
10
+ body,
11
+ }: {
12
+ eyebrow: string;
13
+ title: string;
14
+ body: string;
15
+ }) {
16
+ return (
17
+ <main className="min-h-screen bg-background text-foreground">
18
+ <div className="mx-auto flex min-h-screen max-w-6xl flex-col px-6 py-6">
19
+ <nav className="flex items-center justify-between">
20
+ <Link className="text-sm font-semibold text-foreground" href={appRoutes.publicHome}>
21
+ Vuilder Shell
22
+ </Link>
23
+ <div className="flex items-center gap-2">
24
+ <Button asChild variant="ghost">
25
+ <Link href={appRoutes.pricing}>Pricing</Link>
26
+ </Button>
27
+ <Button asChild variant="ghost">
28
+ <Link href={appRoutes.docsIndex}>Docs</Link>
29
+ </Button>
30
+ <Button asChild>
31
+ <Link href={appRoutes.login}>Log in</Link>
32
+ </Button>
33
+ </div>
34
+ </nav>
35
+
36
+ <section className="grid flex-1 content-center gap-8 py-16">
37
+ <div className="max-w-3xl space-y-5">
38
+ <p className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
39
+ {eyebrow}
40
+ </p>
41
+ <h1 className="text-5xl font-semibold tracking-tight text-balance sm:text-6xl">
42
+ {title}
43
+ </h1>
44
+ <p className="max-w-2xl text-lg leading-8 text-muted-foreground">
45
+ {body}
46
+ </p>
47
+ <Button asChild className="w-fit">
48
+ <Link href={appRoutes.login}>
49
+ Continue
50
+ <ArrowRight className="size-4" />
51
+ </Link>
52
+ </Button>
53
+ </div>
54
+ </section>
55
+ </div>
56
+ </main>
57
+ );
58
+ }
@@ -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
+ });