Completes the v0.1.0 security model. Every /join is now gated by a
signed invite that the broker re-verifies against the mesh owner's
ed25519 pubkey, plus an atomic single-use counter.
schema (migrations/0001_demonic_karnak.sql):
- mesh.mesh.owner_pubkey: ed25519 hex of the invite signer
- mesh.invite.token_bytes: canonical signed bytes (for re-verification)
Both nullable; required for new meshes going forward.
canonical invite format (signed bytes):
`${v}|${mesh_id}|${mesh_slug}|${broker_url}|${expires_at}|
${mesh_root_key}|${role}|${owner_pubkey}`
wire format — invite payload in ic://join/<base64url(JSON)> now has:
owner_pubkey: "<64 hex>"
signature: "<128 hex>"
broker joinMesh() (apps/broker/src/broker.ts):
1. verify ed25519 signature over canonical bytes using payload's
owner_pubkey → else invite_bad_signature
2. load mesh, ensure mesh.owner_pubkey matches payload's owner_pubkey
→ else invite_owner_mismatch (prevents a malicious admin from
substituting their own owner key)
3. load invite row by token, verify mesh_id matches → else
invite_mesh_mismatch
4. expiry check → else invite_expired
5. revoked check → else invite_revoked
6. idempotency: if pubkey is already a member, return existing id
WITHOUT burning an invite use
7. atomic CAS: UPDATE used_count = used_count + 1 WHERE used_count <
max_uses → if 0 rows affected, return invite_exhausted
8. insert member with role from payload
cli side:
- apps/cli/src/invite/parse.ts: zod-validated owner_pubkey + signature
fields; client verifies signature immediately and rejects tampered
links (fail-fast before even touching the broker)
- buildSignedInvite() helper: owners sign invites client-side
- enrollWithBroker sends {invite_token, invite_payload, peer_pubkey,
display_name} (was: {mesh_id, peer_pubkey, display_name, role})
- parseInviteLink is now async (libsodium ready + verify)
seed-test-mesh.ts generates an owner keypair, sets mesh.owner_pubkey,
builds + signs an invite, stores the invite row, emits ownerPubkey +
ownerSecretKey + inviteToken + inviteLink in the output JSON.
tests — invite-signature.test.ts (9 new):
- valid signed invite → join succeeds
- tampered payload → invite_bad_signature
- signer not the mesh owner → invite_owner_mismatch
- expired invite → invite_expired
- revoked invite → invite_revoked
- exhausted (maxUses=2, 3rd join) → invite_exhausted
- idempotent re-join doesn't burn a use
- atomic single-use: 5 concurrent joins → exactly 1 success, 4 exhausted
- mesh_id payload vs DB row mismatch → invite_mesh_mismatch
verified live: tampered link blocked client-side with a clear error.
Unmodified link joins cleanly end-to-end (roundtrip.ts + join-roundtrip.ts
both pass). 64/64 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TurboStarter Kit
Full-stack monorepo built with Next.js, Expo, Turborepo, and pnpm workspaces.
Prerequisites
Project Structure
apps/
web/ # Next.js web application (port 3000)
mobile/ # Expo React Native app
packages/
ai/ # AI provider integrations
analytics/ # Analytics providers
api/ # tRPC API layer
auth/ # Authentication (BetterAuth)
billing/ # Payment providers (Stripe, Lemon Squeezy, Polar)
cms/ # Content management
db/ # Database (Drizzle ORM + PostgreSQL)
email/ # Email providers (Resend, Sendgrid, etc.)
i18n/ # Internationalization
monitoring/# Monitoring (Sentry, PostHog)
shared/ # Shared utilities and config
storage/ # File storage (S3/MinIO)
ui/ # Shared UI components
Quick Start
1. Install dependencies
pnpm install
2. Configure environment variables
Copy the example env files:
# Root env (database, product name, URL)
cp .env.example .env
# Web app env (auth, billing, email, storage, AI, etc.)
cp apps/web/.env.example apps/web/.env.local
Root .env — minimum required variables:
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5440/core"
PRODUCT_NAME="TurboStarter"
URL="http://localhost:3000"
DEFAULT_LOCALE="en"
Note: The database port is
5440(mapped from Docker), not the default5432.
apps/web/.env.local — key variables to configure:
| Variable | Description | Required |
|---|---|---|
BETTER_AUTH_SECRET |
Auth token signing secret | Yes |
NEXT_PUBLIC_AUTH_PASSWORD |
Enable password auth (true/false) |
Yes |
NEXT_PUBLIC_URL |
Public URL of the web app | Yes |
STRIPE_SECRET_KEY |
Stripe key (if using Stripe billing) | Optional |
RESEND_API_KEY |
Resend key (if using Resend email) | Optional |
S3_* |
S3/MinIO storage credentials | Optional |
OPENAI_API_KEY |
OpenAI key (if using AI features) | Optional |
For local MinIO storage, use these S3 settings in apps/web/.env.local:
S3_REGION="us-east-1"
S3_BUCKET="uploads"
S3_ENDPOINT="http://localhost:9000"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
See apps/web/.env.example for the full list of available variables.
3. Start infrastructure (Docker Compose)
Start PostgreSQL and MinIO:
docker compose up -d
Wait for services to be healthy:
docker compose up -d --wait
Or use the built-in shortcut:
pnpm services:start
4. Set up the database
Run migrations and seed data:
pnpm services:setup
This runs docker compose up -d --wait, then applies database migrations and seeds initial data.
5. Start development
pnpm dev
The web app will be available at http://localhost:3000.
Docker Commands
Infrastructure Services
| Command | Description |
|---|---|
docker compose up -d |
Start all services (PostgreSQL + MinIO) |
docker compose down |
Stop all services |
docker compose logs -f |
Follow service logs |
docker compose ps |
Show service status |
Or use the pnpm shortcuts:
| Command | Description |
|---|---|
pnpm services:start |
Start Docker services and wait for healthy |
pnpm services:stop |
Stop Docker services |
pnpm services:logs |
Follow Docker service logs |
pnpm services:status |
Show Docker service status |
pnpm services:setup |
Start services + run DB migrations + seed |
Service URLs
| Service | URL | Credentials |
|---|---|---|
| Web App | http://localhost:3000 | — |
| PostgreSQL | localhost:5440 | turbostarter / turbostarter |
| MinIO API | http://localhost:9000 | minioadmin / minioadmin |
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin |
Production Build (Docker)
Build and run the web app as a production Docker image:
docker build -t turbostarter-web .
docker run -p 3000:3000 --env-file apps/web/.env.local turbostarter-web
Development Commands
| Command | Description |
|---|---|
pnpm dev |
Start all apps in development mode |
pnpm build |
Build all packages and apps |
pnpm lint |
Run ESLint across the monorepo |
pnpm format |
Check formatting with Prettier |
pnpm format:fix |
Fix formatting |
pnpm typecheck |
Run TypeScript type checking |
pnpm test |
Run tests |
pnpm auth:seed |
Seed auth dev accounts |
Database Commands
Run from the root (or within packages/db):
| Command | Description |
|---|---|
pnpm --filter @turbostarter/db db:migrate |
Run database migrations |
pnpm --filter @turbostarter/db db:push |
Push schema changes |
pnpm --filter @turbostarter/db db:generate |
Generate new migration |
pnpm --filter @turbostarter/db db:studio |
Open Drizzle Studio |
pnpm --filter @turbostarter/db db:reset |
Reset database |
pnpm --filter @turbostarter/db db:seed |
Seed database |
Dev Login Credentials
After running pnpm services:setup or pnpm auth:seed:
| Role | Password | |
|---|---|---|
| User | me+user@turbostarter.dev |
Pa$$w0rd |
| Admin | me+admin@turbostarter.dev |
Pa$$w0rd |