Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,37 @@ pnpm install
pnpm dev
```

This starts your app in development mode, rebuilding assets on file changes.
Requires Node.js 22.12 or newer and pnpm 11. This starts the site at http://localhost:3000 and rebuilds on file changes. No environment file, Cloudflare account, database, or sibling repository is required. Internet access is needed to download dependencies and read remote documentation.

### Optional services

Add only the credentials you need to `.env.local`, then restart `pnpm dev`. Environment files are read from this checkout, never from another checkout. Missing credentials disable the relevant local service, they do not prevent the site from starting.

| Service | Optional configuration | Without it |
| ------------------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Public docs | Sibling repositories or `TANSTACK_LOCAL_REPOS_DIR` | Reads public docs from GitHub. Local files take precedence. `TANSTACK_DOCS_USE_REMOTE=true` skips local files. |
| GitHub data | `GITHUB_AUTH_TOKEN` | Public REST and raw-file requests remain anonymous, with GitHub's rate limits. Private sponsor data is omitted. |
| Database features | `DATABASE_URL`, with the repository migrations applied | Showcase and Intent registry lists, plus the feedback leaderboard, are empty. Accounts and writes are unavailable. |
| GitHub sign-in | Database plus `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` | GitHub sign-in is hidden. |
| Google sign-in | Database plus `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` | Google sign-in is hidden. |
| Persistent sessions | `SESSION_SECRET` | Development generates a random worker-local secret. Restarting or reloading the worker signs you out. Production requires a configured secret. |
| Shopify | `SHOPIFY_PRIVATE_STOREFRONT_TOKEN` | The shop shows an unconfigured state with a link to the live shop. |
| Workers AI | `CLOUDFLARE_API_TOKEN`, plus `CLOUDFLARE_ACCOUNT_ID` to use your own account | Remote bindings are disabled. The included chat model is unavailable. Local R2, Durable Objects, and Workflows still use the local emulator. |
| TanChat and connected accounts | Database, sign-in, chat access, and `ENCRYPTION_KEY` of at least 32 characters | Private chat and encrypted connected-account storage are unavailable. Provider API keys can be added in chat settings once chat is configured. |
| AI Gateway | `AI_GATEWAY_ACCOUNT_ID`, `AI_GATEWAY_ID`, `AI_GATEWAY_TOKEN` | Configured providers connect directly. |
| Kody | `KODY_ORIGIN` and `ENCRYPTION_KEY`, then connect an account | Kody actions require a connected account. |
| Notifications | `DISCORD_WEBHOOK_URL` and/or `RESEND_API_KEY` | The corresponding notification transport is disabled. |
| Server error reporting | `SENTRY_DSN` | Local error reporting is disabled. Browser reporting and analytics only run in production. |
| Kapa docs assistant | `VITE_KAPA_INTEGRATION_ID` and optional `VITE_KAPA_SOURCE_GROUP_IDS` | The hosted docs assistant is unavailable. |
| WebContainer runtime | `VITE_WEBCONTAINER_API_KEY` where required by the runtime | Other Builder runtimes remain available. |

For a database you own, set `DATABASE_URL` and run `pnpm db:migrate`. OAuth apps must permit the local callback URL, `http://localhost:3000/api/auth/callback/github` or `/google`. Use a persistent session secret when testing saved sessions or encrypted site API keys.

Cloudflare's included model makes real remote requests and can incur usage charges once a Cloudflare API token is supplied. Local storage stays in `.wrangler/state`. Production bindings and deployment configuration remain in `wrangler.jsonc`.

### Local Setup

The documentation for all TanStack projects (except `React Charts`) is hosted on [tanstack.com](https://tanstack.com). In production, doc pages are fetched from GitHub. In development, they're read from your local file system.
The documentation for all TanStack projects (except `React Charts`) is hosted on [tanstack.com](https://tanstack.com). Doc pages are fetched from GitHub. In development, a local copy takes precedence when available. You only need the additional repositories if you're editing their docs.

Create a `tanstack` parent directory and clone this repo alongside the projects:

Expand Down Expand Up @@ -91,4 +117,4 @@ TanChat is invite-only, independent of Builder access. Signing in to TanStack do

Other users unlock TanChat by redeeming a single-use invite and receive three invites to share. Issuing a link consumes one invite, even if the link expires unused. Links expire after seven days. Account settings links to `/chat-access`, where users can create and copy invites. The main site menu links to TanChat, and accounts without access see the invite page.

Migration `0039_tanchat_invites` adds grants and hashed invite tokens. Server functions and chat HTTP endpoints enforce access independently of the UI. Quota checks lock the issuer account, and redemption locks the invite while granting access and consuming it in one transaction. The local app uses the shared configured database, so invite grants also persist for future production releases.
Migration `0039_tanchat_invites` adds grants and hashed invite tokens. Server functions and chat HTTP endpoints enforce access independently of the UI. Quota checks lock the issuer account, and redemption locks the invite while granting access and consuming it in one transaction. Invite grants are stored in whichever database you configure. Local development without a database has no invite or account state.
67 changes: 67 additions & 0 deletions harness-tests/core/local-services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('~/db/client', () => ({
db: new Proxy(
{},
{
get() {
throw new Error('Unconfigured database was accessed')
},
},
),
isDatabaseConfigured: vi.fn(async () => false),
}))
import { isDatabaseConfigured } from '~/db/client'
import { getAuthService, getSessionService } from '~/auth/context.server'
import {
getAllVerifiedPackages,
getIntentRegistryStats,
getLatestIntentVersionSkillSummaries,
getPackageByName,
searchSkills,
} from '~/utils/intent-db.server'
import { searchShowcasesCore } from '~/utils/showcase.server'

afterEach(() => vi.unstubAllEnvs())

describe('local services without credentials', () => {
it('does not require a session secret for local anonymous browsing', async () => {
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('SESSION_SECRET', undefined)
expect(getSessionService()).toBeDefined()
expect(
await getAuthService().getCurrentUser(
new Request('http://localhost:3000'),
),
).toBeNull()
})
it('keeps public database lists usable without a database', async () => {
vi.stubEnv('NODE_ENV', 'development')
expect(await getAllVerifiedPackages()).toEqual([])
expect(await getPackageByName('@tanstack/query')).toBeUndefined()
expect(await getLatestIntentVersionSkillSummaries()).toEqual([])
expect(await searchSkills('query')).toEqual([])
expect(await getIntentRegistryStats()).toEqual({
packageCount: 0,
skillCount: 0,
versionCount: 0,
})
expect(await searchShowcasesCore()).toEqual({
showcases: [],
pagination: { page: 1, pageSize: 24, total: 0, totalPages: 0 },
})
})
it('uses the database when it is supplied locally', async () => {
vi.stubEnv('NODE_ENV', 'development')
vi.mocked(isDatabaseConfigured).mockResolvedValueOnce(true)
await expect(getAllVerifiedPackages()).rejects.toThrow(
'Unconfigured database was accessed',
)
})
it('does not hide a missing production database', async () => {
vi.stubEnv('NODE_ENV', 'production')
await expect(getAllVerifiedPackages()).rejects.toThrow(
'Unconfigured database was accessed',
)
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,6 @@
"vitest": "^4.1.11"
},
"engines": {
"node": ">=22.0.0"
"node": ">=22.12.0"
}
}
7 changes: 7 additions & 0 deletions src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* and user retrieval. Uses inversion of control for all dependencies.
*/

import { isDatabaseConfigured } from '~/db/client'

import type {
AuthUser,
Capability,
Expand Down Expand Up @@ -33,6 +35,11 @@ export class AuthService implements IAuthService {
* Returns null if not authenticated
*/
async getCurrentUser(request: Request): Promise<AuthUser | null> {
if (
process.env.NODE_ENV === 'development' &&
!(await isDatabaseConfigured())
)
return null
const signedCookie = this.sessionService.getSessionCookie(request)

if (!signedCookie) {
Expand Down
15 changes: 4 additions & 11 deletions src/auth/context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
// Environment Configuration
// ============================================================================

const developmentSessionSecret = crypto.randomUUID()

function getSessionSecret(): string {
const secret = process.env.SESSION_SECRET
if (!secret) {
Expand All @@ -28,17 +30,8 @@ function getSessionSecret(): string {
'SESSION_SECRET environment variable is required in production',
)
}
// In development, require explicit opt-in to use insecure default
if (process.env.ALLOW_INSECURE_SESSION_SECRET !== 'true') {
throw new Error(
'SESSION_SECRET environment variable is required. ' +
'Set ALLOW_INSECURE_SESSION_SECRET=true to use insecure default in development.',
)
}
console.warn(
'[Auth] WARNING: Using insecure session secret for development. Do NOT use in production.',
)
return 'dev-secret-key-change-in-production'
// Sessions are local to this worker unless a persistent secret is supplied.
return developmentSessionSecret
}
return secret
}
Expand Down
4 changes: 4 additions & 0 deletions src/chat/server/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export function adapterFor(
const observedFetch = (protocol: ProviderProtocol) =>
observer ? { fetch: observeProviderFetch(protocol, observer) } : undefined
if (c.provider === 'included') {
if (!env.AI)
throw new Error(
'The included model is unavailable on this server. Add a provider API key in settings.',
)
const binding = env.AI_GATEWAY_ID ? metadataOnlyBinding(env.AI) : env.AI
return createCloudflareText(c.model || env.INCLUDED_MODEL, {
binding: observer ? observeProviderBinding(binding, observer) : binding,
Expand Down
48 changes: 32 additions & 16 deletions src/components/LoginModal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { getSignInProviders } from '~/utils/auth.functions'
import { GithubIcon } from '~/components/icons/GithubIcon'
import { GoogleIcon } from '~/components/icons/GoogleIcon'
import type { OAuthProvider } from '~/auth/types'
Expand All @@ -21,28 +23,42 @@ export function LoginModal({
onOpenChange,
onSocialSignIn,
}: LoginModalProps) {
const providers = useQuery({
queryKey: ['sign-in-providers'],
queryFn: () => getSignInProviders(),
staleTime: Infinity,
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="xs">
<DialogHeader title="Sign in to continue" description={description} />
<DialogBody className="pb-6">
<div className="space-y-3">
<button
type="button"
onClick={() => onSocialSignIn('github')}
className="w-full flex items-center justify-center gap-2 bg-background-inverse text-text-inverse font-medium py-2.5 px-4 rounded-lg transition-colors hover:bg-background-inverse/90"
>
<GithubIcon className="w-5 h-5" />
Continue with GitHub
</button>
<button
type="button"
onClick={() => onSocialSignIn('google')}
className="w-full flex items-center justify-center gap-2 bg-[#DB4437] hover:bg-[#c53929] text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
<GoogleIcon className="w-5 h-5" />
Continue with Google
</button>
{providers.data &&
!providers.data.github &&
!providers.data.google && (
<p>Sign-in isn't configured for this server.</p>
)}
{providers.data?.github && (
<button
type="button"
onClick={() => onSocialSignIn('github')}
className="w-full flex items-center justify-center gap-2 bg-background-inverse text-text-inverse font-medium py-2.5 px-4 rounded-lg transition-colors hover:bg-background-inverse/90"
>
<GithubIcon className="w-5 h-5" />
Continue with GitHub
</button>
)}
{providers.data?.google && (
<button
type="button"
onClick={() => onSocialSignIn('google')}
className="w-full flex items-center justify-center gap-2 bg-[#DB4437] hover:bg-[#c53929] text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
<GoogleIcon className="w-5 h-5" />
Continue with Google
</button>
)}
</div>
</DialogBody>
</DialogContent>
Expand Down
4 changes: 4 additions & 0 deletions src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ function getDatabaseUrl() {
return connectionString
}

export async function isDatabaseConfigured() {
return Boolean(await getDatabaseConnectionString())
}

function getRequestDb(context: DatabaseContext) {
if (!context.db) {
const database = createDatabase(context.connectionString)
Expand Down
6 changes: 5 additions & 1 deletion src/instrument.server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { redactByokRequestHeaders } from './utils/sentry-redaction'

if (!Sentry.getClient())
Sentry.init({
dsn: 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536',
dsn:
process.env.SENTRY_DSN ??
'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536',
enabled:
process.env.NODE_ENV === 'production' || Boolean(process.env.SENTRY_DSN),
integrations: [],
sendDefaultPii: true,
// Performance Monitoring
Expand Down
1 change: 1 addition & 0 deletions src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ if (typeof document !== 'undefined') {

Sentry.init({
dsn: 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536',
enabled: import.meta.env.PROD,
sendDefaultPii: true,
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
Expand Down
4 changes: 3 additions & 1 deletion src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ export const Route = createRootRouteWithContext<{
},
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
],
scripts: [{ children: GOOGLE_ANALYTICS_BOOTSTRAP }],
scripts: import.meta.env.PROD
? [{ children: GOOGLE_ANALYTICS_BOOTSTRAP }]
: [],
}
},
headers: () => DOCUMENT_CACHE_HEADERS,
Expand Down
7 changes: 7 additions & 0 deletions src/routes/auth/$provider/start.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getSignInProviders } from '~/utils/auth.functions'
import { oauthPopupChannelSchema } from '~/auth/oauth-popup'
import { createFileRoute } from '@tanstack/react-router'
import { env } from '~/utils/env'
Expand Down Expand Up @@ -39,6 +40,12 @@ export const Route = createFileRoute('/auth/$provider/start')({
return Response.redirect(new URL('/login', request.url), 302)
}

const providers = await getSignInProviders()
if (!providers[provider])
return new Response('Sign-in is not configured for this server.', {
status: 503,
})

// Generate random state token for CSRF protection
const state = generateOAuthState()

Expand Down
64 changes: 40 additions & 24 deletions src/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { getSignInProviders } from '~/utils/auth.functions'
import * as React from 'react'
import { authClient } from '~/auth/client'
// Using public asset URLs for splash images
Expand Down Expand Up @@ -64,36 +66,50 @@ function SplashImage() {
}

export function SignInForm({ returnTo }: { returnTo?: string } = {}) {
const providers = useQuery({
queryKey: ['sign-in-providers'],
queryFn: () => getSignInProviders(),
staleTime: Infinity,
})
return (
<Card className="p-8 w-[100vw] max-w-sm mx-auto">
<SplashImage />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 text-center">
Sign into TanStack
</h2>
<button
type="button"
onClick={() =>
authClient.signIn.social({
provider: 'github',
returnTo,
})
}
className="w-full bg-black/80 hover:bg-black text-white dark:text-black dark:bg-white/95 dark:hover:bg-white font-semibold py-2 px-4 rounded-md transition-colors"
>
<GithubIcon className="inline-block mr-2 -mt-0.5" /> Sign in with GitHub
</button>
<button
type="button"
onClick={() =>
authClient.signIn.social({
provider: 'google',
returnTo,
})
}
className="w-full bg-[#DB4437]/95 hover:bg-[#DB4437] text-white font-semibold py-2 px-4 rounded-md transition-colors mt-4"
>
<GoogleIcon className="inline-block mr-2 -mt-0.5" /> Sign in with Google
</button>
{providers.data && !providers.data.github && !providers.data.google && (
<p>Sign-in isn't configured for this server.</p>
)}
{providers.data?.github && (
<button
type="button"
onClick={() =>
authClient.signIn.social({
provider: 'github',
returnTo,
})
}
className="w-full bg-black/80 hover:bg-black text-white dark:text-black dark:bg-white/95 dark:hover:bg-white font-semibold py-2 px-4 rounded-md transition-colors"
>
<GithubIcon className="inline-block mr-2 -mt-0.5" /> Sign in with
GitHub
</button>
)}
{providers.data?.google && (
<button
type="button"
onClick={() =>
authClient.signIn.social({
provider: 'google',
returnTo,
})
}
className="w-full bg-[#DB4437]/95 hover:bg-[#DB4437] text-white font-semibold py-2 px-4 rounded-md transition-colors mt-4"
>
<GoogleIcon className="inline-block mr-2 -mt-0.5" /> Sign in with
Google
</button>
)}
</Card>
)
}
Expand Down
Loading
Loading