A small SaaS slice: a marketing site that converts visitors into paid subscribers, plus an authenticated rich-text document workspace unlocked by an active subscription.
Stack: SvelteKit (Svelte 5) · Tailwind CSS v4 (CSS custom-property brand tokens) · tRPC v11
(fetch adapter) · Drizzle ORM (SQLite via @libsql/client) · Zod · Auth.js (@auth/sveltekit,
Credentials + JWT sessions) · Stripe (test mode, subscriptions) · Tiptap (rich text) · Vitest.
Scaffolded with the official sv create CLI (SvelteKit +
TypeScript + Tailwind v4 + Drizzle/libsql + Vitest + ESLint/Prettier add-ons); everything else
(auth, tRPC, Stripe, editor, tests) was built on top.
Video walkthrough: Watch on Google Drive
Covers: landing page, register, pay (Stripe test checkout), and create / edit / save / rename / delete document.
-
Install dependencies
pnpm install
-
Configure environment variables
cp .env.example .env
Fill in the values (see Environment variables below). For local development without testing payments,
STRIPE_*can be left as placeholders — everything except the checkout/webhook flow will work. -
Create the database
pnpm db:migrate
This applies the SQL migration in
drizzle/to a local SQLite file (local.db, path controlled byDATABASE_URL). -
Run the app
pnpm dev
Visit
http://localhost:5173. -
(Optional) Run tests
pnpm test
Browser
│
├─ SvelteKit pages (Svelte 5 runes)
│ ├─ "/" marketing/landing page
│ ├─ "/register", "/login" Auth.js Credentials sign-up/sign-in
│ └─ "/app/**" subscriber workspace (server-guarded in hooks.server.ts)
│
├─ tRPC client (httpBatchLink → POST/GET /trpc/*)
│ └─ src/routes/trpc/[...trpc]/+server.ts (@trpc/server fetch adapter)
│ ├─ auth.register (publicProcedure)
│ ├─ documents.* (subscriberProcedure — DB re-checked every call)
│ └─ billing.createCheckoutSession / getSubscriptionStatus (protectedProcedure)
│
└─ Stripe Checkout (redirect) ──► Stripe ──► webhook (raw body, signature-verified)
src/routes/api/stripe/webhook/+server.ts
→ src/lib/server/stripe/webhook-handler.ts
→ writes users.subscription_status in SQLite
Data flow for the core conversion loop: visitor → /register (tRPC auth.register hashes
password with @node-rs/argon2 and inserts a users row) → Auth.js signIn('credentials')
issues a JWT session cookie → user lands on /app as a non-subscriber (upsell banner, create
disabled) → clicks a plan → billing.createCheckoutSession creates/reuses a Stripe customer and a
Checkout Session (subscription mode) → Stripe redirects back to /app → the Stripe webhook
(not the redirect) flips users.subscription_status to active → the document workspace unlocks.
Authorization model: every document-mutating tRPC procedure runs through a subscriberProcedure
middleware that re-reads subscription_status from the database on every call — it never trusts
the JWT session or any client-supplied state. Document reads/writes are additionally scoped to
WHERE user_id = <caller> so one subscriber can never touch another's documents (covered by
src/lib/trpc/routers/documents.spec.ts).
- Auto-save only, no manual save/version history. A single debounced (800ms) autosave was
chosen over manual save for simplicity; with more time I'd add optimistic conflict detection
(e.g. an
updatedAtcompare-and-swap) and basic version history. - JWT sessions, not DB sessions. Auth.js's Credentials provider doesn't pair well with the database session strategy, so sessions are stateless JWTs. This is still fully server-validated (signed, verified per request), but there's no server-side session revocation list — "sign out everywhere" isn't possible without adding one. Subscription status itself is always read fresh from the DB per request (never cached in the JWT), so access is revoked essentially instantly even though the session token itself can't be revoked early.
- No email verification, no OAuth. Both are explicitly out of scope for this assignment; the Credentials provider is sufficient. Auth.js supports adding an OAuth provider with a few lines if needed later.
- No Stripe Customer Portal / billing management UI. Called out as a bonus in the assignment;
skipped to stay inside the time box.
getSubscriptionStatusexists so the UI could add a "Manage billing" link with one more Stripe API call. - Minimal toolbar (bold/italic/heading/lists). Tiptap supports much more; the assignment only requires "rich text," so the extension set was kept small.
- Native delete confirmation (
window.confirm) instead of a custom modal — meets the "confirm before delete" requirement with the least code; a styled modal would be the first UI polish item with more time. - No rate limiting / brute-force protection on login or register. Would add next for a real
deployment (e.g. per-IP throttling on
auth.registerand the Credentialsauthorize()callback).
-
Use a Stripe test-mode secret key (
sk_test_...) and create a recurring Price in the test dashboard; put its ID inSTRIPE_PRICE_ID. -
Forward webhooks to your local server with the Stripe CLI:
pnpm stripe:listen
Copy the printed
whsec_...value intoSTRIPE_WEBHOOK_SECRET. -
Register an account, then start checkout from the pricing section on
/or the upsell banner on/app. -
On the Stripe Checkout page, use the test card 4242 4242 4242 4242, any future expiry, any CVC, any postal code.
-
After payment, Stripe fires
checkout.session.completedto the CLI-forwarded webhook, which updatesusers.subscription_statustoactivein SQLite — the workspace unlocks as soon as that write lands (no reliance on the redirect back from Stripe).
- No password reset flow.
- No pagination on the document list (fine at small scale; would add for many documents).
- Single SQLite file — not intended for concurrent multi-process production deployment as-is.
- No end-to-end (Playwright) tests, only Vitest unit/integration tests for server-side authorization and Stripe webhook logic — see Testing.
local.db is a plain SQLite file (path set by DATABASE_URL). Browse or edit tables with
Drizzle Studio (no extra install — already a dev dependency):
pnpm db:studioOpens a web UI at https://local.drizzle.studio for browsing/editing rows against the current
schema.
See .env.example for the full list:
| Variable | Purpose |
|---|---|
DATABASE_URL |
SQLite file URL for Drizzle/libsql, e.g. file:local.db |
AUTH_SECRET |
Auth.js JWT signing secret — generate with openssl rand -hex 32 |
STRIPE_SECRET_KEY |
Stripe test-mode secret key |
STRIPE_WEBHOOK_SECRET |
Signing secret from stripe listen (or a configured webhook) |
STRIPE_PRICE_ID |
Stripe recurring Price ID used for the subscription Checkout |
PUBLIC_APP_URL |
Base URL used to build Stripe Checkout success/cancel redirect URLs |
No secrets are committed — only .env.example (with empty values) is tracked in git.
pnpm testRuns Vitest against an isolated in-memory SQLite DB per test (src/lib/server/db/test-utils.ts),
covering:
- Password hashing round-trip (
src/lib/server/auth/password.spec.ts) - Stripe webhook handler updates the right user on
checkout.session.completed/customer.subscription.deleted, and ignores unrelated event types (src/lib/server/stripe/webhook-handler.spec.ts) - Document authorization: unauthenticated calls rejected, non-subscribers blocked, subscribers
cannot read/write another user's documents (
src/lib/trpc/routers/documents.spec.ts)
pnpm check # svelte-check (TypeScript)
pnpm lint # prettier --check + eslint
pnpm format # prettier --write
pnpm db:studio # Drizzle Studio (browse the local SQLite DB)
pnpm stripe:listen # Stripe CLI: forward webhooks to localhost:5173/api/stripe/webhook