Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9a9132e
feat(setup): setup wizard with browser-based Chat key handoff
TheodoreSpeaks Jul 24, 2026
090342c
improvement(setup): drop the paste-a-key fallback, simplify consent copy
TheodoreSpeaks Jul 24, 2026
af8ad44
fix(setup): pin kube context, keep secrets out of argv, validate reus…
TheodoreSpeaks Jul 24, 2026
65e6ef1
refactor(copilot): one module for Chat API key operations
TheodoreSpeaks Jul 24, 2026
d362e68
improvement(setup): reuse shared helpers, parallelize probes, drop de…
TheodoreSpeaks Jul 24, 2026
209e6ec
fix(setup): make doctor understand the compose env layout
TheodoreSpeaks Jul 24, 2026
43de45d
feat(cli-auth): device-authorization poll flow, drop the loopback lis…
TheodoreSpeaks Jul 24, 2026
08d13fa
fix(setup): reuse an existing managed Postgres container instead of c…
TheodoreSpeaks Jul 24, 2026
9db7998
improvement(setup): audience-first run-mode hints
TheodoreSpeaks Jul 24, 2026
e00b02d
Merge remote-tracking branch 'origin/staging' into feat/cli-auth-key
TheodoreSpeaks Jul 24, 2026
8e8c5ca
fix(cli-auth): retry a failed mint, port container/port fixes to Redi…
TheodoreSpeaks Jul 24, 2026
c5a6607
fix(setup): harden mint cleanup, Windows browser, container detection…
TheodoreSpeaks Jul 24, 2026
fd1a900
feat(chat-keys): standalone manage page, drop from settings nav, refr…
TheodoreSpeaks Jul 24, 2026
cb49a61
fix(setup): per-key reason in the secret-replacement warning
TheodoreSpeaks Jul 24, 2026
cf61a14
fix(setup): compose doctor schema, cross-platform binary detection, q…
TheodoreSpeaks Jul 24, 2026
a393319
fix(setup): quote kube-context in the helm uninstall tear-down hint too
TheodoreSpeaks Jul 24, 2026
f24f237
feat(setup): sim lifecycle CLI — start/stop/status/logs/down/reset
TheodoreSpeaks Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 17 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,71 +75,40 @@ Docker must be installed and running. Use `-p, --port <port>` to run Sim on a di

## Self-hosting

### Docker Compose
**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/).

```bash
git clone https://github.com/simstudioai/sim.git && cd sim
docker compose -f docker-compose.prod.yml up -d
```

Open [http://localhost:3000](http://localhost:3000)

Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.

### Manual Setup

**Requirements:** [Bun](https://bun.sh/), [Node.js](https://nodejs.org/) v20+, PostgreSQL 12+ with [pgvector](https://github.com/pgvector/pgvector)

1. Clone and install:

```bash
git clone https://github.com/simstudioai/sim.git
cd sim
bun install
bun run prepare # Set up pre-commit hooks
bun run setup
```

2. Set up PostgreSQL with pgvector:
`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose:

```bash
docker run --name simstudio-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_DB=simstudio -p 5432:5432 -d pgvector/pgvector:pg17
```
- **Local dev** — run from source to contribute or hack on Sim
- **Docker Compose** — a self-contained instance for testing self-hosting
- **Kubernetes (Helm)** — deploy to a local cluster

Or install manually via the [pgvector guide](https://github.com/pgvector/pgvector#installation).
When it finishes, open [http://localhost:3000](http://localhost:3000).

3. Configure environment:
Manage your install with the `sim` CLI — run `bun link` once so `sim` works anywhere (or prefix `bun run`):

```bash
cp apps/sim/.env.example apps/sim/.env
# Create your secrets
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
# DB configs for migration
cp packages/db/.env.example packages/db/.env
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
sim start | stop | restart # bring your install up / down / cycle
sim status # what's installed and healthy
sim logs # follow logs
sim doctor # diagnose configuration problems
sim down # remove containers (data kept)
sim reset # archive .env and wipe managed data
```

4. Run migrations:
`sim` detects how you're running (Docker Compose, local dev, or Kubernetes) and acts accordingly.

```bash
cd packages/db && bun run db:migrate
```

5. Start development servers:

```bash
bun run dev:full # Starts Next.js app and realtime socket server
```

Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [self-hosting docs](https://docs.sim.ai/self-hosting/docker) for details.

## Chat API Keys

Chat is a Sim-managed service. To use Chat on a self-hosted instance:

- Go to https://sim.ai → Settings → Chat keys and generate a Chat API key
- Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/account/settings/chat-keys](https://sim.ai/account/settings/chat-keys).

## Environment Variables

Expand Down
30 changes: 30 additions & 0 deletions apps/sim/app/(auth)/auth-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Where the user goes once authentication finishes, carried across the login →
* signup → verify hops. Written only after `validateCallbackUrl` accepts it, and
* re-validated on read.
*/
export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'

interface AuthCrossLinkParams {
/** Validated post-auth destination to carry over, or null to drop it. */
callbackUrl: string | null
isInviteFlow: boolean
}

/**
* Builds the login ⇄ signup cross-link, preserving the post-auth destination so
* a visitor who signs up instead of signing in still lands where they were
* headed. `URLSearchParams` does the encoding — a destination that carries its
* own query string (`/cli/auth?callback=…&state=…`) must survive intact.
*/
export function buildAuthCrossLink(
path: '/login' | '/signup',
{ callbackUrl, isInviteFlow }: AuthCrossLinkParams
): string {
const params = new URLSearchParams()
if (isInviteFlow) params.set('invite_flow', 'true')
if (callbackUrl) params.set('callbackUrl', callbackUrl)

const query = params.toString()
return query ? `${path}?${query}` : path
}
11 changes: 6 additions & 5 deletions apps/sim/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent } from '@/lib/posthog/client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import {
AuthDivider,
AuthField,
Expand Down Expand Up @@ -107,6 +108,10 @@ export default function LoginPage({
}
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
const signupHref = buildAuthCrossLink('/signup', {
callbackUrl: isValidCallbackUrl ? callbackUrl : null,
isInviteFlow,
})
Comment thread
cursor[bot] marked this conversation as resolved.

const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
Expand Down Expand Up @@ -431,11 +436,7 @@ export default function LoginPage({
)}

{emailEnabled && (
<AuthNavPrompt
prompt="Don't have an account?"
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
linkLabel='Sign up'
/>
<AuthNavPrompt prompt="Don't have an account?" href={signupHref} linkLabel='Sign up' />
)}

<AuthLegalFooter action='signing in' />
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
import {
AuthDivider,
AuthField,
Expand Down Expand Up @@ -343,9 +344,8 @@ function SignupFormContent({

if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailValue)
if (isInviteFlow && redirectUrl) {
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
sessionStorage.setItem('isInviteFlow', 'true')
if (redirectUrl) {
sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
}
}

Expand Down Expand Up @@ -468,7 +468,7 @@ function SignupFormContent({

<AuthNavPrompt
prompt='Already have an account?'
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
href={buildAuthCrossLink('/login', { callbackUrl: redirectUrl || null, isInviteFlow })}
linkLabel='Sign in'
/>

Expand Down
122 changes: 55 additions & 67 deletions apps/sim/app/(auth)/verify/use-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,39 @@ import { normalizeEmail } from '@sim/utils/string'
import { useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'

const logger = createLogger('useVerification')

/**
* Resolves the post-auth destination at the moment of redirect rather than
* caching it in state.
*
* Both redirect sites run in the same commit as the effect that reads session
* storage, so a cached value is still `null` when they fire and the stored
* destination is silently replaced by `/workspace`. Reading here removes that
* race. `redirectAfter` wins over the stored URL; anything failing callback
* validation is discarded, and an unsafe stored value is evicted.
*/
function resolveRedirectUrl(redirectParam: string | null): string | null {
let resolved: string | null = null

const stored = sessionStorage.getItem(POST_AUTH_REDIRECT_STORAGE_KEY)
if (stored && validateCallbackUrl(stored)) {
resolved = stored
} else if (stored) {
logger.warn('Ignoring unsafe stored post-auth redirect URL', { url: stored })
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
}

if (redirectParam) {
if (validateCallbackUrl(redirectParam)) resolved = redirectParam
else logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
}

return resolved
}

/**
* Mutually-exclusive phases of the email-OTP verification machine.
* - `idle`: awaiting input
Expand Down Expand Up @@ -53,44 +83,11 @@ export function useVerification({
const [isResending, setIsResending] = useState(false)
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
const [isInviteFlow, setIsInviteFlow] = useState(false)

useEffect(() => {
if (typeof window !== 'undefined') {
const storedEmail = sessionStorage.getItem('verificationEmail')
if (storedEmail) {
setEmail(storedEmail)
}

const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
setRedirectUrl(storedRedirectUrl)
} else if (storedRedirectUrl) {
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
sessionStorage.removeItem('inviteRedirectUrl')
}

const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
if (storedIsInviteFlow === 'true') {
setIsInviteFlow(true)
}
}

const redirectParam = searchParams.get('redirectAfter')
if (redirectParam) {
if (validateCallbackUrl(redirectParam)) {
setRedirectUrl(redirectParam)
} else {
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
}
}

const inviteFlowParam = searchParams.get('invite_flow')
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
}, [searchParams])
const storedEmail = sessionStorage.getItem('verificationEmail')
if (storedEmail) setEmail(storedEmail)
}, [])

useEffect(() => {
if (email && !isSendingInitialOtp && hasEmailService) {
Expand Down Expand Up @@ -122,21 +119,12 @@ export function useVerification({
logger.warn('Failed to refetch session after verification', e)
}

if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')

if (isInviteFlow) {
sessionStorage.removeItem('inviteRedirectUrl')
sessionStorage.removeItem('isInviteFlow')
}
}
const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace'
sessionStorage.removeItem('verificationEmail')
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)

setTimeout(() => {
if (isInviteFlow && redirectUrl) {
window.location.href = redirectUrl
} else {
window.location.href = '/workspace'
}
window.location.href = destination
}, 1000)
} else {
logger.info('Setting invalid OTP state - API error response')
Expand Down Expand Up @@ -217,28 +205,28 @@ export function useVerification({
}, [otp, email, status, isResending])

useEffect(() => {
if (typeof window !== 'undefined') {
if (!isEmailVerificationEnabled) {
setStatus('verified')
if (isEmailVerificationEnabled) return

const handleRedirect = async () => {
try {
await refetchSession()
} catch (error) {
logger.warn('Failed to refetch session during verification skip:', error)
}

if (isInviteFlow && redirectUrl) {
window.location.href = redirectUrl
} else {
router.push('/workspace')
}
}
setStatus('verified')

handleRedirect()
const destination = resolveRedirectUrl(searchParams.get('redirectAfter'))

const handleRedirect = async () => {
try {
await refetchSession()
} catch (error) {
logger.warn('Failed to refetch session during verification skip:', error)
}

if (destination) {
window.location.href = destination
} else {
router.push('/workspace')
}
}
}, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl])

handleRedirect()
}, [isEmailVerificationEnabled, router, searchParams])
Comment thread
cursor[bot] marked this conversation as resolved.

return {
otp,
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/(auth)/verify/verify-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Suspense, useEffect, useState } from 'react'
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
import {
AuthFormMessage,
AuthHeader,
Expand Down Expand Up @@ -140,8 +141,7 @@ function VerificationForm({
onNavigate={() => {
if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')
sessionStorage.removeItem('inviteRedirectUrl')
sessionStorage.removeItem('isInviteFlow')
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
}
}}
/>
Expand Down
3 changes: 1 addition & 2 deletions apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
parseSettingsPathSection,
} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isPlatformAdmin } from '@/lib/permissions/super-user'

interface AccountSettingsSectionPageProps {
Expand Down Expand Up @@ -46,7 +46,6 @@ export default async function AccountSettingsSectionPage({
})
if (!parsed) notFound()
if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general'))
if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general'))
if (parsed === 'admin' || parsed === 'mothership') {
const isSuperUser = await isPlatformAdmin(session.user.id)
if (!isSuperUser) notFound()
Expand Down
Loading
Loading