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
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
# Tanstack Start - With E2E Encryption Demo
# Tanstack Start - E2E Encrypted SaaS Starter

A TanStack Start starter with Convex, Clerk, and end-to-end encryption.
> ! Not yet ready. Currently in development phase.

Full-stack React starter with zero-knowledge architecture. Sensitive data is encrypted client-side before it reaches the server; the backend never has access to decryption keys or plaintext.
A ~~production-ready~~ (soon) SaaS dashboard starter built with TanStack Start, Convex, and Clerk — with end-to-end encryption baked in from day one.

Use this starter as the foundation for any SaaS that handles sensitive user data: note-taking apps, document editors, personal finance tools, health trackers, or anything where users deserve to know their data is private by design.

## The Problem With Most SaaS

Most SaaS products store user data in plaintext. This means the company (and anyone who gains access to their infrastructure) can read your users' data at any given time.

This starter takes a different approach. Sensitive data is encrypted on the client before it ever leaves the browser. The server only stores ciphertext and it has no access to decryption keys or plaintext, ever.

## What This Starter Provides

A full-stack SaaS dashboard template where you can build features on top of a privacy-first foundation:

- **Authenticated dashboard** with per-user encrypted document storage
- **Client-side encryption** — data is encrypted before upload, decrypted after download
- **Zero-knowledge key management** — keys are derived from the user's password, never sent to the server
- **Real-time sync** via Convex, without ever exposing plaintext to the backend
- **Drop-in auth** via Clerk with seamless key provisioning on sign-up/sign-in

## Tech Stack

Expand All @@ -13,7 +31,9 @@ Full-stack React starter with zero-knowledge architecture. Sensitive data is enc
- **UI:** Tailwind CSS, shadcn, Radix UI
- **State:** Jotai

## Zero-Knowledge Architecture
## How It Works — Zero-Knowledge Architecture

This is the foundation of a **zero-knowledge architecture**: the service provider genuinely cannot read what users store.

```mermaid
sequenceDiagram
Expand Down
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @module
*/

import type * as documents from "../documents.js";
import type * as userKeys from "../userKeys.js";

import type {
Expand All @@ -17,6 +18,7 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
documents: typeof documents;
userKeys: typeof userKeys;
}>;

Expand Down
57 changes: 57 additions & 0 deletions convex/documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { v } from 'convex/values'

import type { Id } from './_generated/dataModel'
import { mutation, query } from './_generated/server'

/**
* Get the documents for the user
*/
export const getDocuments = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null

const documents = await ctx.db
.query('documents')
.withIndex('by_user', (q) => q.eq('userId', identity.subject))
.collect()

return documents
},
})

/**
* Get a single document by id
*/
export const getDocumentById = query({
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null

const document = await ctx.db.get('documents', args.id as Id<'documents'>)
if (!document || document.userId !== identity.subject) return null

return document
},
})

/**
* Create a new document for the user
*/
export const createDocument = mutation({
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) throw new Error('Unauthorized')

return await ctx.db.insert('documents', {
id: crypto.randomUUID().toString(),
userId: identity.subject,
createdAt: Date.now(),
encryptedData: args.encryptedData,
updatedAt: Date.now(),
})
},
args: {
encryptedData: v.string(),
},
})
17 changes: 17 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
/**
* Documents
* This table stores the documents for the user.
* It is used to store the encrypted data for the documents.
*/
documents: defineTable({
id: v.string(),
userId: v.string(),
createdAt: v.number(),
encryptedData: v.string(),
updatedAt: v.number(),
})
.index('by_user', ['userId'])
.index('id', ['id'])
.index('createdAt', ['createdAt'])
.index('updatedAt', ['updatedAt']),

/**
* User keys
* This table stores the user's encryption keys.
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Dashboard",
"encrypt": "Encryption Demo",
"documents": "Documents",
"logout": "Logout"
},
"sign_in": {
Expand Down
1 change: 1 addition & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Tableau de bord",
"encrypt": "Démo de chiffrement",
"documents": "Documents",
"logout": "Se déconnecter"
},
"sign_in": {
Expand Down
16 changes: 11 additions & 5 deletions src/components/Layouts/Navigation/MainNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useNavigate } from '@tanstack/react-router'
import { LayoutDashboardIcon, MessageSquareLockIcon } from 'lucide-react'
import {
FileIcon,
LayoutDashboardIcon,
MessageSquareLockIcon,
} from 'lucide-react'

import {
SidebarGroup,
Expand All @@ -25,10 +29,12 @@ export const MainNavigation = () => {
/>

<NavigationLink
icon={<MessageSquareLockIcon />}
label={m['navigation.encrypt']()}
tooltip={m['navigation.encrypt']()}
onClick={() => navigate({ to: RoutesPath.DEMO_ENCRYPT.toString() })}
icon={<FileIcon />}
label={m['navigation.documents']()}
tooltip={m['navigation.documents']()}
onClick={() =>
navigate({ to: RoutesPath.DOCUMENTS_LIST.toString() })
}
/>
</SidebarMenu>
</SidebarGroupContent>
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useEncryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ export const useEncryption = () => {
return {
decryptData,
encryptData,
userKeys,
}
}
89 changes: 72 additions & 17 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DemoEncryptRouteImport } from './routes/demo/encrypt'
import { Route as DocumentsListRouteImport } from './routes/documents/list'
import { Route as DocumentsCreateRouteImport } from './routes/documents/create'
import { Route as AuthSignupRouteImport } from './routes/auth/signup'
import { Route as AuthSigninRouteImport } from './routes/auth/signin'
import { Route as DocumentsDetailsIdRouteImport } from './routes/documents/details.$id'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DemoEncryptRoute = DemoEncryptRouteImport.update({
id: '/demo/encrypt',
path: '/demo/encrypt',
const DocumentsListRoute = DocumentsListRouteImport.update({
id: '/documents/list',
path: '/documents/list',
getParentRoute: () => rootRouteImport,
} as any)
const DocumentsCreateRoute = DocumentsCreateRouteImport.update({
id: '/documents/create',
path: '/documents/create',
getParentRoute: () => rootRouteImport,
} as any)
const AuthSignupRoute = AuthSignupRouteImport.update({
Expand All @@ -34,39 +41,71 @@ const AuthSigninRoute = AuthSigninRouteImport.update({
path: '/auth/signin',
getParentRoute: () => rootRouteImport,
} as any)
const DocumentsDetailsIdRoute = DocumentsDetailsIdRouteImport.update({
id: '/documents/details/$id',
path: '/documents/details/$id',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/auth/signin': typeof AuthSigninRoute
'/auth/signup': typeof AuthSignupRoute
'/demo/encrypt': typeof DemoEncryptRoute
'/documents/create': typeof DocumentsCreateRoute
'/documents/list': typeof DocumentsListRoute
'/documents/details/$id': typeof DocumentsDetailsIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/auth/signin': typeof AuthSigninRoute
'/auth/signup': typeof AuthSignupRoute
'/demo/encrypt': typeof DemoEncryptRoute
'/documents/create': typeof DocumentsCreateRoute
'/documents/list': typeof DocumentsListRoute
'/documents/details/$id': typeof DocumentsDetailsIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/auth/signin': typeof AuthSigninRoute
'/auth/signup': typeof AuthSignupRoute
'/demo/encrypt': typeof DemoEncryptRoute
'/documents/create': typeof DocumentsCreateRoute
'/documents/list': typeof DocumentsListRoute
'/documents/details/$id': typeof DocumentsDetailsIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt'
fullPaths:
| '/'
| '/auth/signin'
| '/auth/signup'
| '/documents/create'
| '/documents/list'
| '/documents/details/$id'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt'
id: '__root__' | '/' | '/auth/signin' | '/auth/signup' | '/demo/encrypt'
to:
| '/'
| '/auth/signin'
| '/auth/signup'
| '/documents/create'
| '/documents/list'
| '/documents/details/$id'
id:
| '__root__'
| '/'
| '/auth/signin'
| '/auth/signup'
| '/documents/create'
| '/documents/list'
| '/documents/details/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthSigninRoute: typeof AuthSigninRoute
AuthSignupRoute: typeof AuthSignupRoute
DemoEncryptRoute: typeof DemoEncryptRoute
DocumentsCreateRoute: typeof DocumentsCreateRoute
DocumentsListRoute: typeof DocumentsListRoute
DocumentsDetailsIdRoute: typeof DocumentsDetailsIdRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -78,11 +117,18 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/encrypt': {
id: '/demo/encrypt'
path: '/demo/encrypt'
fullPath: '/demo/encrypt'
preLoaderRoute: typeof DemoEncryptRouteImport
'/documents/list': {
id: '/documents/list'
path: '/documents/list'
fullPath: '/documents/list'
preLoaderRoute: typeof DocumentsListRouteImport
parentRoute: typeof rootRouteImport
}
'/documents/create': {
id: '/documents/create'
path: '/documents/create'
fullPath: '/documents/create'
preLoaderRoute: typeof DocumentsCreateRouteImport
parentRoute: typeof rootRouteImport
}
'/auth/signup': {
Expand All @@ -99,14 +145,23 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSigninRouteImport
parentRoute: typeof rootRouteImport
}
'/documents/details/$id': {
id: '/documents/details/$id'
path: '/documents/details/$id'
fullPath: '/documents/details/$id'
preLoaderRoute: typeof DocumentsDetailsIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthSigninRoute: AuthSigninRoute,
AuthSignupRoute: AuthSignupRoute,
DemoEncryptRoute: DemoEncryptRoute,
DocumentsCreateRoute: DocumentsCreateRoute,
DocumentsListRoute: DocumentsListRoute,
DocumentsDetailsIdRoute: DocumentsDetailsIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
2 changes: 1 addition & 1 deletion src/routes/auth/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai'

import { passphraseAtom } from '@/stores/encryptionAtoms'
import { RoutesPath } from '@/types/routes'
import { SignInView } from '@/views/SignInView'
import { SignInView } from '@/views/AuthViews/SignInView'

export const Route = createFileRoute(RoutesPath.SIGN_IN)({
component: RouteComponent,
Expand Down
2 changes: 1 addition & 1 deletion src/routes/auth/signup.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'

import { RoutesPath } from '@/types/routes'
import { SignUpView } from '@/views/SignUpView'
import { SignUpView } from '@/views/AuthViews/SignUpView'

export const Route = createFileRoute(RoutesPath.SIGN_UP)({
component: RouteComponent,
Expand Down
20 changes: 20 additions & 0 deletions src/routes/documents/create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createFileRoute } from '@tanstack/react-router'

import { Layout } from '@/components/Layouts'
import { ProtectedRoutes } from '@/components/ProtectedRoutes'
import { RoutesPath } from '@/types/routes'
import { DocumentCreationView } from '@/views/DocumentsView'

export const Route = createFileRoute(RoutesPath.DOCUMENTS_CREATE)({
component: RouteComponent,
})

function RouteComponent() {
return (
<ProtectedRoutes>
<Layout>
<DocumentCreationView />
</Layout>
</ProtectedRoutes>
)
}
Loading