CLAUDE.md

The map your AI agent reads

This is the actual CLAUDE.md shipped withShipPad — the architecture brief Claude Code (and any coding agent) loads to find its way around the codebase. It updates straight from the repo.

View on GitHub →

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

Shippad is a SaaS monorepo built on SST v3 (AWS). The apex domain serves a static Astro marketing site + blog (SEO, landing components, OG cards, lead capture); the React Router 7 app lives at app.<domain>. Plus an OpenAuth issuer (Google + email code, brand-themed), a tRPC API (rate-limited public endpoints), provider-agnostic payments (Dodo implemented), SES email (transactional + marketing campaigns to users and leads, plus inbound receiving), a referral/affiliate program, organization/team multi-tenancy (every user gets a personal org; org-scoped billing), a superadmin app, and prod CloudWatch alarms, all backed by a single DynamoDB table.

Commands

Development

Package-specific

Secrets

infra/secrets.ts is the source of truth for which secrets exist (and their defaults); set any with npx sst secret set <NAME> <value>. SETUP.md covers which are required vs optional and where each value comes from.

Admin

npx sst shell tsx scripts/make-admin.ts <email>   # promote a user (they must sign in once first)

Setup & production

First-run setup (stamp, secrets, OAuth, first deploy, optional features) is in SETUP.md. The one-time production runbook (SES out of the sandbox, DMARC, inbound region limits, Dodo live mode, alarms SNS confirm, web push, static-site headers) is in RUNBOOK.md.

Architecture

Monorepo Structure

Key Technical Patterns

  1. Single-Table DynamoDB via ElectroDB: All entities live in one table (infra/table.ts) with pk/sk + gsi1 + gsi2 + gsi3. Each domain in packages/core/src/domain/[name]/ has entity.ts (ElectroDB), schema.ts (Zod), index.ts (operations + exports). Enumerating an entity type needs a static-partition collection index (e.g. LEADS, CAMPAIGNS, USERS) — without one, "list all" falls back to a full-table scan, which in single-table design pages by raw items examined, not matches, so it both over-reads and returns misleading cursors. User is the one entity whose gsi1/gsi2 are both spent on auth lookups (GOOGLE#/EMAIL#), so its USERS collection lives on gsi3. Every write populates the gsi3 keys, so a fresh deploy needs no migration; only if you add a new collection index onto an entity that already has rows would those rows need re-saving to appear in it.
  2. Auth: OpenAuth issuer (packages/functions/auth/) with Google OAuth and passwordless email codes (CodeProvider + CodeUI, codes delivered via SES) on DynamoDB storage — the issuer shows a provider-select screen. The web app keeps the access token in an httpOnly cookie session (apps/web/app/lib/auth.server.ts) and verifies it per request. The API verifies the bearer token per tRPC request.
  3. Billing via a provider-agnostic payments layer (packages/core/src/payments/): the PaymentProvider interface (types.ts) has one implementation per provider — dodo.ts (implemented), stripe.ts/paddle.ts (stubs that throw PAYMENTS_PROVIDER_NOT_IMPLEMENTED). The active provider is PAYMENT_PROVIDER in constants.ts. billing.createCheckout calls Payments.createCheckoutSession (the provider resolves its own product/secrets; userId travels in metadata). Dodo seeds the cart with DODO_PRODUCT_ID; setting the optional DODO_PRODUCT_COLLECTION_ID adds product_collection_id so checkout shows a plan switcher (e.g. monthly/yearly) and the portal offers upgrade/downgrade. DODO_ENVIRONMENT is set per stage in infra/api.ts (live_mode on prod, test_mode elsewhere) — keys/product IDs must match the mode. billing.createPortal returns the hosted customer portal (manage/cancel/invoices). The webhook Lambda (packages/functions/payments/webhook.ts) routes by path (/dodo, /stripe, /paddle), verifies via the provider's verifyAndParseWebhook, and upserts the provider-neutral Subscription entity from the normalized event — it is the source of truth. Billing is org-scoped: the Subscription is keyed ORG#${orgId} and checkout metadata carries both orgId (owns the subscription) and userId (the purchaser — used for the activation email + referral credit). Gate paid features with Subscription.getEntitlements(subscription) (fetched via getSubscriptionForOrg) — PLAN_LIMITS in core/src/domain/subscription/entitlements.ts is the single place that maps plans to limits (a missing/inactive subscription resolves to free).
  4. tRPC (packages/functions/api/): publicProcedure / protectedProcedure (narrows ctx.userId). Routers in api/router/, registered in api/router/index.ts. AppRouter type is consumed by the web app for end-to-end types.
  5. KSUID IDs: createId(prefix) from @shippad/core/utils — time-sortable with domain prefixes (user_...).
  6. Errors: ApplicationError subclasses with [DOMAIN]_[ERROR_TYPE] codes; ForbiddenError is mapped to tRPC FORBIDDEN automatically.
  7. Email via SES (sst.aws.Email in infra/email.ts, send layer in packages/core/src/email/): Email.sendTransactional for account updates (always delivered) and Email.sendMarketing for campaigns (appends an HMAC-signed unsubscribe footer + List-Unsubscribe headers; check the recipient's opt-in first). Transactional templates are React Email components in packages/email/ — async helpers (welcomeEmail, loginCodeEmail, subscriptionActiveEmail) render HTML + plain text; add new templates as emails/[name].tsx + a helper in src/index.tsx, preview with cd packages/email && pnpm dev. Campaign bodies stay admin-authored HTML. Campaign sends go through CampaignQueue (SQS) → packages/functions/email/campaign-sender.ts so the API returns instantly.
  8. Superadmin: a separate app (apps/admin/, deployed at admin.<domain> via infra/admin.ts) with its own session cookie (admin_session, OpenAuth clientID admin) against the shared issuer. requireAdmin in its auth.server.ts checks user.role === 'admin' (non-admins land on /denied); every admin tRPC procedure re-enforces the role server-side via adminProcedure (packages/functions/api/trpc.ts). Admin tRPC router: admin.stats, admin.users.*, admin.campaigns.*. Bootstrap the first admin with npx sst shell tsx scripts/make-admin.ts <email>. admin.stats reads a cached Stats snapshot (domain/stats/) recomputed at most hourly, so dashboard loads don't re-enumerate every user + lead on each request. The admin app is itself a PWA (sidebar AdminNav with a mobile drawer, installable, web-push opt-in for new support chats + inbound mail) — same machinery as the web app's PWA (pattern 21), shippad-themed. Its support + inbox screens are a live master-detail (list pane + Outlet detail), kept fresh by the realtime firehoses (self-hosted support chat, pattern 27).
  9. Domains: infra/domain.ts maps prod → apex, other stages → {stage}.domain. The static site owns the apex (SEO); the app at app., auth at auth., admin at admin.. infra/site.ts injects APP_URL/API_URL env at build so stage sites link to their stage app and the waitlist form posts to the right API. Leave zoneId empty to deploy without custom domains. core/constants.ts exports prod SITE_URL/APP_URL for emails/SEO fallbacks.
  10. Leads / waitlist: lead domain in core (email-keyed, time-ordered list gsi). Public lead.subscribe endpoint (rate-limited per IP) fed by the LeadCapture landing component. Lead.addLead returns { lead, isNewSubscription }; the router sends the waitlistWelcomeEmail (via Email.sendMarketing, best-effort) only on a fresh opt-in so returning re-submitters aren't re-mailed. Campaign sends go to opted-in users and subscribed leads (deduped by email); leads get their own HMAC unsubscribe tokens (kind: 'lead', id = email).
  11. Rate limiting: checkRateLimit in core/utils/rate-limit.ts — fixed-window counter in the app table, self-cleaning via TTL. Throws RateLimitError (RATE_LIMITED); map it to tRPC TOO_MANY_REQUESTS in routers. Used on lead.subscribe and email.unsubscribe.
  12. Analytics: ANALYTICS config in core/constants.ts (provider + id, off when id is empty). <Analytics /> (from @shippad/ui) in the web root renders the snippet (ga4 = Firebase Analytics via gtag.js; plausible/datafast/posthog also supported); trackEvent() from @shippad/ui for custom events. The site has its own inline ANALYTICS_ID in Base.astro. The admin app deliberately has none.
  13. SEO: lives on the apex site — Base.astro emits canonical/OG/Twitter tags, @astrojs/sitemap generates the sitemap (with /og/* excluded), public/robots.txt allows crawling. OG cards are real HTML/CSS pages (apps/site/src/pages/og/[slug].astro, rendered with the site's actual Inter webfont) that scripts/generate-og.ts screenshots with headless Chromium into committed public/og/{slug}.png (default + one per blog post). Run pnpm --filter @shippad/site generate:og after npx playwright install chromium; the .githooks/pre-commit hook regenerates them when apps/site/src/ changes. This deliberately replaces sharp/SVG-text rendering, whose fonts depend on the build environment. The app subdomain is noindex (robots.tsx disallows all; root layout has a noindex-safe setup); buildMeta() in apps/web/app/lib/seo.tsx remains for app pages.
  14. Support: Crisp widget via CRISP_WEBSITE_ID in constants (<CrispScript /> in web root); <SupportLink> opens Crisp when loaded, else mailto:SUPPORT_EMAIL.
  15. Landing pages: composable React sections in packages/ui/src/landing/ (Header, Hero, Problem, FeaturesGrid, Testimonials, Pricing, FAQ, CTA, Footer), rendered statically by Astro in apps/site/src/pages/index.astro (Header is hydrated client:load for the theme toggle). They use plain anchors — app links cross domains via the appUrl prop (stage-aware, injected by SST) defaulting to APP_URL. The shared Header/Footer render on every site page (landing, blog, legal) so logged-out surfaces look consistent. The waitlist form is apps/site/src/components/LeadCapture.astro — vanilla JS posting to the public lead.subscribe tRPC endpoint (CORS is open on the API function URL).
  16. Styling: each app's CSS does @import 'tailwindcss'; @import '@shippad/ui/styles.css'; @source '../../../packages/ui/src'; — the @source directive is required so Tailwind scans classes used inside the shared package (pnpm symlinks under node_modules are skipped by default). Brand tokens and the --font-sans (self-hosted Inter via @fontsource-variable, woff2 bundled per app, same-origin) live only in packages/ui/src/styles.css. The site imports the same stylesheet (its @source path has one extra ../); emails keep a system font stack (webfonts are unreliable in email clients). The OpenAuth screens are themed to match in packages/functions/auth/handler.ts (theme + Select provider labels).
  17. Alarms: infra/alarms.ts (prod only) — Lambda errors on the API and payments webhook notify ALERT_EMAIL via SNS (confirm the subscription email once).
  18. Uploads: presigned flow in core/src/storage/media.uploadUrl (tRPC) returns a content-type-pinned PUT URL under uploads/{userId}/; the browser PUTs directly to S3 and stores the key on the owning record. createViewUrl(key) presigns GETs for display; the dashboard avatar (apps/web/app/components/AvatarUpload.tsx) is the reference implementation. user.picture holds either an OAuth URL or an S3 key — Storage.resolvePictureUrl(picture) collapses both to a displayable URL (presigning S3 keys). New Google sign-ins mirror the OAuth avatar into our own S3 via Storage.mirrorRemoteImage (best-effort, falls back to the source URL), so the auth issuer is linked to the uploads bucket (infra/auth.ts).
  19. Account deletion: user.deleteAccount (tRPC) → User.deleteUserAccount — refuses with USER_HAS_ACTIVE_SUBSCRIPTION until the subscription is cancelled via the portal, then deletes the user + subscription records and the dashboard destroys the session.
  20. Dark mode: class-based (@custom-variant dark in ui/styles.css). Semantic colors (background/foreground/card/border/muted/muted-foreground) are CSS vars swapped by .dark — use them instead of gray-scale utilities. THEME_INIT_SCRIPT (inline in each root layout) applies localStorage/system preference before paint; <ThemeToggle /> flips and persists it. The blog honors the same script inline.
  21. PWA (web app + admin app): public/manifest.webmanifest + icon.svg/icon-maskable.svg
    • public/sw.js (network-first navigations falling back to the pre-cached /offline shell; cache-first for hashed /assets/*; push + notificationclick handlers). Registration + controlled-update handshake live in app/lib/pwa.client.ts (called from root.tsx / shell.tsx, skipped on localhost); a new worker installs but waits until the user accepts via <SwUpdateNotifier />, which posts SKIP_WAITING. <InstallPrompt /> offers add-to-home-screen (with an iOS Safari hint). The /offline route is inline-styled so it renders without cached CSS. The admin app ships the same machinery (its <PushOptIn> lives in the nav footer; opting in alerts operators to new support chats
    • inbound mail). Each app's worker is versioned independently — bump CACHE_VERSION in the relevant sw.js; the .githooks/pre-commit hook (wired via the prepare script) auto-bumps the right one when that app's PWA files are staged.
  22. Web push (VAPID): opt-in via <PushOptIn /> on the dashboard → posts the subscription to the /push/subscribe action → push.subscribe tRPC (rate-limited) → the push domain (core/src/domain/push/, one record per device endpoint, idempotent upsert). The hourly cron (packages/functions/cron/handler.ts) reads the VAPID keys from process.env, fans payloads out via web-push, and prunes stale subscriptions (404/410). It's timezone-aware: <TimezoneSync /> records each user's IANA timezone (user.timezone, via the /me/timezone action → user.setTimezone) so nudges fire at NUDGE_HOUR local time. Replace the placeholder buildNudge with real per-user content. Keys are set as VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY secrets (default empty — the opt-in card and sender self-disable until configured); the public key is exposed to the browser via the Web function env and the dashboard loader.
  23. Referral / affiliate program (core/src/domain/referral/): three entities sharing the table — ReferralCodeEntity (a code registry: every user gets a personal code via getOrCreateUserCode, admins create affiliate codes via admin.affiliates.create), ReferralEntity (an attributed signup, keyed by the referred user so attribution is set-once), and RewardEntity (the payout ledger). Share links are ${APP_URL}/?ref=CODE; the app captures ?ref= into a cookie (apps/web/app/lib/ref.server.ts, set in the home loader before it redirects) and the dashboard loader calls referral.attribute once after login (attributeReferral ignores self-referral / unknown codes / already-attributed users). Conversions are credited in the payments webhook's isActivation branch (Referral.recordConversion, idempotent via a conditional write) — accruing a pending reward of REFERRAL.rewardAmount (in constants.ts, 0 disables; per-affiliate override on the code). Dashboard <ReferralCard /> shows the link + stats (referral.me); admin /affiliates creates affiliates and marks payouts paid (admin.rewards.*).
  24. Inbound email (core/src/domain/inbox/ + packages/functions/email/inbound.ts): SES receiving, wired in infra/inbound.ts and gated behind zoneId (needs a domain whose MX we control; SES inbound is only in us-east-1/us-west-2/eu-west-1, so deploy there). A receipt rule archives raw MIME to the InboundEmail bucket (infra/storage.ts, created on every stage so the admin can presign downloads), then invokes the Lambda, which parses with mailparser, stores headers + a capped text body via Inbox.addMessage, and forwards to the FORWARD_EMAIL secret (empty → archive + store only). Read it in the admin app at /inbox (admin.inbox.*); the detail view presigns the raw .eml from the bucket (Inbox.getRawUrl). Note: SES allows one active receipt rule set per region/account.
  25. Multi-tenancy (core/src/domain/org/, membership/, invite/): one ownership model — every user gets a personal org on signup (User.findOrCreate*Org.ensurePersonalOrg, also a lazy fallback in resolveCurrentOrg), and a solo user is just an org of one. TEAMS_ENABLED (constants.ts) gates only the UI (switcher, /team, invites) — the data model is always tenant-scoped, so flipping it on needs no migration. The API resolves the active org from the x-org-id header via orgProcedure (api/trpc.ts), falling back to the personal org; the web app sends the org chosen in <OrgSwitcher> (persisted in the session). org.* router handles members/invites/roles (Membership.requireMembership(orgId, userId, roles) enforces, throwing ForbiddenError → tRPC FORBIDDEN). Invites are token links (InviteEntity gsi by token, TTL-expiring); /invite/:token accepts after login (stashed in a cookie like the referral flow, invite.server.ts). Account deletion refuses while an owned org has an active subscription or the user solely owns a team org. Billing keys off the current org; the admin UI uses Subscription.getSubscriptionForUser (resolves the user's personal org).
  26. Admin API keys (core/src/domain/apikey/): the whole admin.* tRPC surface is reachable programmatically so a builder running several products on this repo can wire them into one unified dashboard. Keys are minted in the admin console at /api-keys (admin.apiKeys.*, on adminSessionProcedurenever callable with a key, only a session, so a leaked key can't mint or escalate keys). The plaintext secret (lp_sk_…) is shown once at creation; only its SHA-256 hash is stored (ApiKeyEntity, keyed by that hash for O(1) lookup). The API context (api/handler.ts) falls back from the Bearer JWT to an x-api-key header → resolves the key → authenticates as its owner; adminProcedure still re-checks the owner is an admin, so demoting them disables their keys. Keys default to read-only; the per-key read-write toggle is enforced at protectedProcedure (read-only ⇒ mutations rejected for every authenticated procedure, not just admin). touchLastUsed records activity, throttled to one write/minute. Revoking deletes the record. Endpoints are plain tRPC-over-HTTP (queries GET /<proc>?input=…, mutations POST), and TS consumers can reuse the exported AppRouter type with x-api-key in headers. The API Function URL CORS (infra/api.ts) lists x-api-key/authorization explicitly (the * wildcard doesn't cover them).
  27. Self-hosted support chat (core/src/domain/support/, functions/realtime/ + functions/support/, ui/src/support/): AWS IoT Core (MQTT over WebSockets, infra/realtime.ts) is the transport; the shared <SupportWidget> runs on both the app (authenticated transport) and the marketing site (anonymous transport, conversation token in localStorage + a registrable-domain cookie so the app can claim the thread post-login). Messages always go through tRPC (support.*) so they're rate-limited + persisted before being broadcast; clients only ever subscribe. The IoT custom authorizer (realtime/authorizer.ts) is deliberately DB-free — it imports just the HMAC token verifiers via @shippad/core/domain/support/token (not the barrel, which would construct every entity) and links only the session secret. Two hard IoT-policy rules it encodes: never return subscribe: []/publish: [] (SST emits a malformed Resource: [] statement → AWS refuses the whole connection — omit the key instead), and never grant an MQTT wildcard like conv/+ (AWS rejects it in iot:Receive). So operators get two firehoses only — agent/inbox (support activity) + agent/mail (inbound email) — and view a single thread with a conversation-scoped token, exactly like the visitor side. Ephemeral "typing…" pings (support.typing/typingVisitor/admin.support.typing) publish straight to the conversation topic with no DB write. The browser client (ui/src/support/realtime.ts) follows SST's pattern (handlers first, manualConnect, unique clientId, qos 1) and surfaces connection errors; opt into verbose logs with localStorage.shippad_support_debug = '1'. Delayed push/email nudges run through SupportNotifyQueuesupport/notify.ts (sendPushToAdmins fans out to every admin's devices). The app has no standalone /support page — the widget is floating, mounted once in root.tsx (<AppSupportWidget>, gated by SUPPORT_CHAT_ENABLED + requireSession) — so the user-side notification deep-links to ${APP_URL}/dashboard?support=1; the widget's defaultOpen opens the panel when ?support=1 is present, dropping the recipient straight into the conversation. The operator-side nudge links to the admin's real /support page. Inbound support email replies route back into the thread (functions/email/inbound.ts); new mail pings agent/mail so the admin inbox refreshes live.
  28. Bot protection (core/src/captcha/): a provider seam shaped exactly like payments — types.ts (CaptchaProvider), turnstile.ts (Cloudflare Turnstile, the implemented one), none.ts (the default no-op so a fresh deploy isn't gated on Cloudflare), index.ts (getCaptchaProvider/verifyCaptcha, selected by CAPTCHA.provider in constants.ts). honeypot.ts adds abuseGuardSchema (a website honeypot field + a client ts timing trap + captchaToken) and the pure looksLikeBot. Public routers merge abuseGuardSchema into their input and call helpers in functions/api/guard.ts: assertHuman (honeypot + captcha — for form-backed endpoints like lead.subscribe, where the Astro form renders the Turnstile widget + honeypot) and rejectHoneypot (honeypot only — support.start, since a chat box can't host a challenge and it's already tightly rate-limited). email.unsubscribe is deliberately not gated (it's a one-click email link). The public siteKey lives in CAPTCHA; the secret is the TURNSTILE_SECRET_KEY secret (linked in infra/api.ts). Verify fails closed.
  29. Cookie consent + analytics gating (ui/src/consent.ts, CookieBanner.tsx, Analytics.tsx): a light banner stores cookie_consent in localStorage and dispatches a cookie-consent-change event. <Analytics> is now a client component that injects the provider script only after consent is granted (nothing tracks before). Mounted on the web app (root.tsx) and the marketing site (site/src/components/CookieConsent.astro, vanilla JS mirroring the same key, gating the inline GA snippet). The admin app has no analytics, so no banner. Essential cookies (the auth session) are exempt and always set.
  30. Error monitoring (core/src/monitoring/): another payments-shaped seam — console.ts (default; structured logger.error → CloudWatch + the prod alarms, or the browser console), none.ts, sentry.ts (a stub that forwards to console until you add the SDK), selected by MONITORING.provider in constants.ts. captureException(error, context) never throws and is wired into both apps' ErrorBoundary (client-side, skipping expected 4xx route responses) and the API handler's tRPC onError (only INTERNAL_SERVER_ERROR). Browser-safe: import via @shippad/core/monitoring (no AWS) in route modules, never the barrel.
  31. GDPR data export (User.exportUserDatauser.exportData tRPC → web /me/export): the read-only mirror of deleteUserAccount — assembles profile, orgs/memberships + subscriptions, referral stats, push subscriptions, and the support thread into one JSON blob. The dashboard "Export my data" link downloads it (the route returns a Content-Disposition attachment). exportUserData composes the other domains in core (like deleteUserAccount), which is safe because referral/push/support don't import the user domain.
  32. Security headers (apps/web/app/entry.server.tsx, apps/admin/app/entry.server.tsx): custom RR7 streaming server entries that set HSTS / X-Content-Type-Options / X-Frame-Options (DENY on admin, SAMEORIGIN on web) / Referrer-Policy / Permissions-Policy on every SSR document response. A strict CSP is left commented (the apps inline scripts + call other origins, so it needs per-deploy tuning). The static site is served straight from CloudFront — harden it with a CloudFront response-headers policy (RUNBOOK). DynamoDB has deletionProtectionEnabled in prod (infra/table.ts).

DynamoDB Key Patterns (ElectroDB templates)

Code Conventions

Domain Implementation Pattern

When adding a new domain to core:

  1. Create packages/core/src/domain/[name]/
  2. Implement in order: entity.ts (ElectroDB) → schema.ts (Zod) → index.ts (operations)
  3. Add tests alongside (index.test.ts for pure logic, entity.test.ts for key shapes via .params())
  4. Export from packages/core/src/index.ts as a namespace (export * as Name from './domain/name')
  5. Add a tRPC router in packages/functions/api/router/[name].ts and register it in router/index.ts

For auth-gated procedures use protectedProcedure — it throws UNAUTHORIZED when no valid token is present and narrows ctx.userId to non-null.

Web App Notes