Skip to content
marcellokaboraPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Inkwell — Web Deployed Rich Text Editor

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.

Demo

Video walkthrough: Watch on Google Drive

Covers: landing page, register, pay (Stripe test checkout), and create / edit / save / rename / delete document.

Setup (should take < 15 minutes on a fresh machine)

  1. Install dependencies

    pnpm install
  2. 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.

  3. Create the database

    pnpm db:migrate

    This applies the SQL migration in drizzle/ to a local SQLite file (local.db, path controlled by DATABASE_URL).

  4. Run the app

    pnpm dev

    Visit http://localhost:5173.

  5. (Optional) Run tests

    pnpm test

Architecture overview

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).

Tradeoffs and what I'd do next with another day

  • 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 updatedAt compare-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. getSubscriptionStatus exists 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.register and the Credentials authorize() callback).

Stripe test flow

  1. Use a Stripe test-mode secret key (sk_test_...) and create a recurring Price in the test dashboard; put its ID in STRIPE_PRICE_ID.

  2. Forward webhooks to your local server with the Stripe CLI:

    pnpm stripe:listen

    Copy the printed whsec_... value into STRIPE_WEBHOOK_SECRET.

  3. Register an account, then start checkout from the pricing section on / or the upsell banner on /app.

  4. On the Stripe Checkout page, use the test card 4242 4242 4242 4242, any future expiry, any CVC, any postal code.

  5. After payment, Stripe fires checkout.session.completed to the CLI-forwarded webhook, which updates users.subscription_status to active in SQLite — the workspace unlocks as soon as that write lands (no reliance on the redirect back from Stripe).

Known limitations

  • 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.

Managing the local database

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:studio

Opens a web UI at https://local.drizzle.studio for browsing/editing rows against the current schema.

Environment variables

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.

Testing

pnpm test

Runs 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)

Other useful scripts

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages