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
pnpm run dev- Start SST dev environment (runs all services locally with live reload)pnpm run format- Format code using Biomepnpm run lint- Lint and auto-fix code using Biomepnpm run check- Run Biome checks (lint + format)pnpm run typecheck- Typecheck all packagespnpm run test- Run all tests (Vitest)
Package-specific
cd packages/core && pnpm test(orpnpm test:watch,pnpm test:coverage)cd apps/web && pnpm typecheck(runsreact-router typegenfirst; same forapps/admin)cd apps/site && pnpm build- Build the Astro site
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
- packages/core: Domain logic — ElectroDB entities, Zod schemas, Dodo Payments client, SES send layer, utilities
- packages/email: React Email templates (
emails/*.tsx, previewable viapnpm dev→ localhost:3010) rendered to HTML + plain text by helpers insrc/index.tsx - packages/ui: shared design system — brand tokens + self-hosted Inter (
src/styles.css), primitives (Button/ButtonLink,Card,Badge,Field/TextareaField,cn), chrome (Analytics,CrispScript/SupportLink,trackEvent), and the landing sections (@shippad/ui/landing) - packages/functions: Lambda handlers —
auth/(OpenAuth issuer),api/(tRPC),payments/(Dodo webhook),email/(campaign queue subscriber + inbound SES receiver),cron/ - apps/web: React Router 7 SSR app at
app.<domain>(Tailwind v4, cookie sessions, PWA) - apps/admin: separate superadmin React Router 7 app, deployed at
admin.<domain> - apps/site: Astro 5 static marketing site + blog at the apex domain (MDX, sitemap, OG cards, renders the shared React landing components via @astrojs/react)
- infra/: SST v3 infrastructure, one file per concern (table, auth, api, web, admin, site, payments, email, queue, cron, storage, secrets, domain)
Key Technical Patterns
- Single-Table DynamoDB via ElectroDB: All entities live in one table (
infra/table.ts) with pk/sk + gsi1 + gsi2 + gsi3. Each domain inpackages/core/src/domain/[name]/hasentity.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-tablescan, which in single-table design pages by raw items examined, not matches, so it both over-reads and returns misleading cursors.Useris the one entity whose gsi1/gsi2 are both spent on auth lookups (GOOGLE#/EMAIL#), so itsUSERScollection 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. - 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. - Billing via a provider-agnostic payments layer (
packages/core/src/payments/): thePaymentProviderinterface (types.ts) has one implementation per provider —dodo.ts(implemented),stripe.ts/paddle.ts(stubs that throwPAYMENTS_PROVIDER_NOT_IMPLEMENTED). The active provider isPAYMENT_PROVIDERinconstants.ts.billing.createCheckoutcallsPayments.createCheckoutSession(the provider resolves its own product/secrets;userIdtravels in metadata). Dodo seeds the cart withDODO_PRODUCT_ID; setting the optionalDODO_PRODUCT_COLLECTION_IDaddsproduct_collection_idso checkout shows a plan switcher (e.g. monthly/yearly) and the portal offers upgrade/downgrade.DODO_ENVIRONMENTis set per stage ininfra/api.ts(live_modeon prod,test_modeelsewhere) — keys/product IDs must match the mode.billing.createPortalreturns 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'sverifyAndParseWebhook, and upserts the provider-neutral Subscription entity from the normalized event — it is the source of truth. Billing is org-scoped: the Subscription is keyedORG#${orgId}and checkout metadata carries bothorgId(owns the subscription) anduserId(the purchaser — used for the activation email + referral credit). Gate paid features withSubscription.getEntitlements(subscription)(fetched viagetSubscriptionForOrg) —PLAN_LIMITSincore/src/domain/subscription/entitlements.tsis the single place that maps plans to limits (a missing/inactive subscription resolves tofree). - tRPC (
packages/functions/api/):publicProcedure/protectedProcedure(narrowsctx.userId). Routers inapi/router/, registered inapi/router/index.ts.AppRoutertype is consumed by the web app for end-to-end types. - KSUID IDs:
createId(prefix)from@shippad/core/utils— time-sortable with domain prefixes (user_...). - Errors:
ApplicationErrorsubclasses with[DOMAIN]_[ERROR_TYPE]codes;ForbiddenErroris mapped to tRPCFORBIDDENautomatically. - Email via SES (
sst.aws.Emailininfra/email.ts, send layer inpackages/core/src/email/):Email.sendTransactionalfor account updates (always delivered) andEmail.sendMarketingfor campaigns (appends an HMAC-signed unsubscribe footer + List-Unsubscribe headers; check the recipient's opt-in first). Transactional templates are React Email components inpackages/email/— async helpers (welcomeEmail,loginCodeEmail,subscriptionActiveEmail) render HTML + plain text; add new templates asemails/[name].tsx+ a helper insrc/index.tsx, preview withcd packages/email && pnpm dev. Campaign bodies stay admin-authored HTML. Campaign sends go throughCampaignQueue(SQS) →packages/functions/email/campaign-sender.tsso the API returns instantly. - Superadmin: a separate app (
apps/admin/, deployed atadmin.<domain>viainfra/admin.ts) with its own session cookie (admin_session, OpenAuth clientIDadmin) against the shared issuer.requireAdminin itsauth.server.tschecksuser.role === 'admin'(non-admins land on/denied); every admin tRPC procedure re-enforces the role server-side viaadminProcedure(packages/functions/api/trpc.ts). Admin tRPC router:admin.stats,admin.users.*,admin.campaigns.*. Bootstrap the first admin withnpx sst shell tsx scripts/make-admin.ts <email>.admin.statsreads a cachedStatssnapshot (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 (sidebarAdminNavwith 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 +Outletdetail), kept fresh by the realtime firehoses (self-hosted support chat, pattern 27). - Domains:
infra/domain.tsmaps prod → apex, other stages →{stage}.domain. The static site owns the apex (SEO); the app atapp., auth atauth., admin atadmin..infra/site.tsinjectsAPP_URL/API_URLenv at build so stage sites link to their stage app and the waitlist form posts to the right API. LeavezoneIdempty to deploy without custom domains.core/constants.tsexports prodSITE_URL/APP_URLfor emails/SEO fallbacks. - Leads / waitlist:
leaddomain in core (email-keyed, time-ordered list gsi). Publiclead.subscribeendpoint (rate-limited per IP) fed by theLeadCapturelanding component.Lead.addLeadreturns{ lead, isNewSubscription }; the router sends thewaitlistWelcomeEmail(viaEmail.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). - Rate limiting:
checkRateLimitincore/utils/rate-limit.ts— fixed-window counter in the app table, self-cleaning via TTL. ThrowsRateLimitError(RATE_LIMITED); map it to tRPCTOO_MANY_REQUESTSin routers. Used onlead.subscribeandemail.unsubscribe. - Analytics:
ANALYTICSconfig incore/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/uifor custom events. The site has its own inlineANALYTICS_IDinBase.astro. The admin app deliberately has none. - SEO: lives on the apex site —
Base.astroemits canonical/OG/Twitter tags, @astrojs/sitemap generates the sitemap (with/og/*excluded),public/robots.txtallows crawling. OG cards are real HTML/CSS pages (apps/site/src/pages/og/[slug].astro, rendered with the site's actual Inter webfont) thatscripts/generate-og.tsscreenshots with headless Chromium into committedpublic/og/{slug}.png(default+ one per blog post). Runpnpm --filter @shippad/site generate:ogafternpx playwright install chromium; the.githooks/pre-commithook regenerates them whenapps/site/src/changes. This deliberately replaces sharp/SVG-text rendering, whose fonts depend on the build environment. The app subdomain is noindex (robots.tsxdisallows all; root layout has a noindex-safe setup);buildMeta()inapps/web/app/lib/seo.tsxremains for app pages. - Support: Crisp widget via
CRISP_WEBSITE_IDin constants (<CrispScript />in web root);<SupportLink>opens Crisp when loaded, elsemailto:SUPPORT_EMAIL. - Landing pages: composable React sections in
packages/ui/src/landing/(Header, Hero, Problem, FeaturesGrid, Testimonials, Pricing, FAQ, CTA, Footer), rendered statically by Astro inapps/site/src/pages/index.astro(Header is hydratedclient:loadfor the theme toggle). They use plain anchors — app links cross domains via theappUrlprop (stage-aware, injected by SST) defaulting toAPP_URL. The sharedHeader/Footerrender on every site page (landing, blog, legal) so logged-out surfaces look consistent. The waitlist form isapps/site/src/components/LeadCapture.astro— vanilla JS posting to the publiclead.subscribetRPC endpoint (CORS is open on the API function URL). - Styling: each app's CSS does
@import 'tailwindcss'; @import '@shippad/ui/styles.css'; @source '../../../packages/ui/src';— the@sourcedirective 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 inpackages/ui/src/styles.css. The site imports the same stylesheet (its@sourcepath has one extra../); emails keep a system font stack (webfonts are unreliable in email clients). The OpenAuth screens are themed to match inpackages/functions/auth/handler.ts(theme+Selectprovider labels). - Alarms:
infra/alarms.ts(prod only) — Lambda errors on the API and payments webhook notifyALERT_EMAILvia SNS (confirm the subscription email once). - Uploads: presigned flow in
core/src/storage/—media.uploadUrl(tRPC) returns a content-type-pinned PUT URL underuploads/{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.pictureholds 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 viaStorage.mirrorRemoteImage(best-effort, falls back to the source URL), so the auth issuer is linked to the uploads bucket (infra/auth.ts). - Account deletion:
user.deleteAccount(tRPC) →User.deleteUserAccount— refuses withUSER_HAS_ACTIVE_SUBSCRIPTIONuntil the subscription is cancelled via the portal, then deletes the user + subscription records and the dashboard destroys the session. - Dark mode: class-based (
@custom-variant darkinui/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. - PWA (web app + admin app):
public/manifest.webmanifest+icon.svg/icon-maskable.svgpublic/sw.js(network-first navigations falling back to the pre-cached/offlineshell; cache-first for hashed/assets/*; push + notificationclick handlers). Registration + controlled-update handshake live inapp/lib/pwa.client.ts(called fromroot.tsx/shell.tsx, skipped on localhost); a new worker installs but waits until the user accepts via<SwUpdateNotifier />, which postsSKIP_WAITING.<InstallPrompt />offers add-to-home-screen (with an iOS Safari hint). The/offlineroute 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_VERSIONin the relevantsw.js; the.githooks/pre-commithook (wired via thepreparescript) auto-bumps the right one when that app's PWA files are staged.
- Web push (VAPID): opt-in via
<PushOptIn />on the dashboard → posts the subscription to the/push/subscribeaction →push.subscribetRPC (rate-limited) → thepushdomain (core/src/domain/push/, one record per device endpoint, idempotent upsert). The hourly cron (packages/functions/cron/handler.ts) reads the VAPID keys fromprocess.env, fans payloads out viaweb-push, and prunes stale subscriptions (404/410). It's timezone-aware:<TimezoneSync />records each user's IANA timezone (user.timezone, via the/me/timezoneaction →user.setTimezone) so nudges fire atNUDGE_HOURlocal time. Replace the placeholderbuildNudgewith real per-user content. Keys are set asVAPID_PUBLIC_KEY/VAPID_PRIVATE_KEYsecrets (default empty — the opt-in card and sender self-disable until configured); the public key is exposed to the browser via theWebfunction env and the dashboard loader. - Referral / affiliate program (
core/src/domain/referral/): three entities sharing the table —ReferralCodeEntity(a code registry: every user gets a personal code viagetOrCreateUserCode, admins createaffiliatecodes viaadmin.affiliates.create),ReferralEntity(an attributed signup, keyed by the referred user so attribution is set-once), andRewardEntity(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 callsreferral.attributeonce after login (attributeReferralignores self-referral / unknown codes / already-attributed users). Conversions are credited in the payments webhook'sisActivationbranch (Referral.recordConversion, idempotent via a conditional write) — accruing a pending reward ofREFERRAL.rewardAmount(inconstants.ts,0disables; per-affiliate override on the code). Dashboard<ReferralCard />shows the link + stats (referral.me); admin/affiliatescreates affiliates and marks payouts paid (admin.rewards.*). - Inbound email (
core/src/domain/inbox/+packages/functions/email/inbound.ts): SES receiving, wired ininfra/inbound.tsand gated behindzoneId(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 theInboundEmailbucket (infra/storage.ts, created on every stage so the admin can presign downloads), then invokes the Lambda, which parses withmailparser, stores headers + a capped text body viaInbox.addMessage, and forwards to theFORWARD_EMAILsecret (empty → archive + store only). Read it in the admin app at/inbox(admin.inbox.*); the detail view presigns the raw.emlfrom the bucket (Inbox.getRawUrl). Note: SES allows one active receipt rule set per region/account. - 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 inresolveCurrentOrg), 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 thex-org-idheader viaorgProcedure(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, throwingForbiddenError→ tRPC FORBIDDEN). Invites are token links (InviteEntitygsi by token, TTL-expiring);/invite/:tokenaccepts 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 usesSubscription.getSubscriptionForUser(resolves the user's personal org). - Admin API keys (
core/src/domain/apikey/): the wholeadmin.*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.*, onadminSessionProcedure— never 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 anx-api-keyheader → resolves the key → authenticates as its owner;adminProcedurestill 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 atprotectedProcedure(read-only ⇒ mutations rejected for every authenticated procedure, not just admin).touchLastUsedrecords 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 exportedAppRoutertype withx-api-keyin headers. The API Function URL CORS (infra/api.ts) listsx-api-key/authorizationexplicitly (the*wildcard doesn't cover them). - 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 returnsubscribe: []/publish: [](SST emits a malformedResource: []statement → AWS refuses the whole connection — omit the key instead), and never grant an MQTT wildcard likeconv/+(AWS rejects it iniot: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 withlocalStorage.shippad_support_debug = '1'. Delayed push/email nudges run throughSupportNotifyQueue→support/notify.ts(sendPushToAdminsfans out to every admin's devices). The app has no standalone/supportpage — the widget is floating, mounted once inroot.tsx(<AppSupportWidget>, gated bySUPPORT_CHAT_ENABLED+requireSession) — so the user-side notification deep-links to${APP_URL}/dashboard?support=1; the widget'sdefaultOpenopens the panel when?support=1is present, dropping the recipient straight into the conversation. The operator-side nudge links to the admin's real/supportpage. Inbound support email replies route back into the thread (functions/email/inbound.ts); new mail pingsagent/mailso the admin inbox refreshes live. - 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 byCAPTCHA.providerinconstants.ts).honeypot.tsaddsabuseGuardSchema(awebsitehoneypot field + a clienttstiming trap +captchaToken) and the purelooksLikeBot. Public routers mergeabuseGuardSchemainto their input and call helpers infunctions/api/guard.ts:assertHuman(honeypot + captcha — for form-backed endpoints likelead.subscribe, where the Astro form renders the Turnstile widget + honeypot) andrejectHoneypot(honeypot only —support.start, since a chat box can't host a challenge and it's already tightly rate-limited).email.unsubscribeis deliberately not gated (it's a one-click email link). The publicsiteKeylives inCAPTCHA; the secret is theTURNSTILE_SECRET_KEYsecret (linked ininfra/api.ts). Verify fails closed. - Cookie consent + analytics gating (
ui/src/consent.ts,CookieBanner.tsx,Analytics.tsx): a light banner storescookie_consentin localStorage and dispatches acookie-consent-changeevent.<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. - Error monitoring (
core/src/monitoring/): another payments-shaped seam —console.ts(default; structuredlogger.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 byMONITORING.providerinconstants.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 tRPConError(onlyINTERNAL_SERVER_ERROR). Browser-safe: import via@shippad/core/monitoring(no AWS) in route modules, never the barrel. - GDPR data export (
User.exportUserData→user.exportDatatRPC → web/me/export): the read-only mirror ofdeleteUserAccount— 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 aContent-Dispositionattachment).exportUserDatacomposes the other domains in core (likedeleteUserAccount), which is safe because referral/push/support don't import the user domain. - 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-Policyon 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 hasdeletionProtectionEnabledin prod (infra/table.ts).
DynamoDB Key Patterns (ElectroDB templates)
- User:
USER#${userId}/PROFILE; gsi1:GOOGLE#${googleId}/USER#${userId}; gsi2:EMAIL#${email}/USER#${userId}; gsi3 (all-users collection):USERS/${createdAt}#${userId} - Org:
ORG#${orgId}/METADATA - Membership:
ORG#${orgId}/MEMBER#${userId}; gsi1:USER#${userId}/ORG#${orgId} - Invite:
ORG#${orgId}/INVITE#${email}; gsi1:INVITETOKEN#${token}/METADATA(TTL-expiring) - Subscription:
ORG#${orgId}/SUBSCRIPTION; gsi1:CUSTOMER#${provider}#${customerId}/ORG#${orgId} - Campaign:
CAMPAIGN#${campaignId}/METADATA; gsi1:CAMPAIGNS/CAMPAIGN#${campaignId} - Push subscription:
USER#${userId}/PUSH#${endpointHash}(one record per device endpoint) - Lead:
LEAD#${email}/METADATA; gsi1:LEADS/${createdAt}#${email} - Referral code:
REFCODE#${code}/METADATA; gsi1:REFCODES#${kind}/${createdAt}#${code}; gsi2 (sparse, user codes):OWNER#${ownerUserId}/REFCODE - Referral:
USER#${referredUserId}/REFERRAL; gsi1:REFCODE#${code}/${createdAt}#${referredUserId} - Referral reward:
REFCODE#${code}/REWARD#${referredUserId}; gsi1:PAYOUTS#${status}/${createdAt}#${referredUserId} - Inbox message:
INBOX#${messageId}/METADATA; gsi1:INBOX/${receivedAt}#${messageId} - API key:
APIKEY#${keyHash}/METADATA; gsi1:APIKEYS/${createdAt}#${keyId} - Stats snapshot:
STATS#${id}/SNAPSHOT(singleglobalrow — cached admin dashboard counts) - Rate limit buckets:
RATELIMIT#${name}#${id}#${window}/METADATA(raw item, TTL-expired)
Code Conventions
- Biome (see
biome.json): 2-space indentation, single quotes, semicolons, 100-char line width - Strict TypeScript: no
any,import typeenforced, no non-null assertions (exceptentity.tstemplates) - Zod for tRPC input validation; Valibot for OpenAuth subject schemas
- Tests collocated with implementation (
*.test.ts), run with Vitest - Unit tests run outside
sst shell;packages/core/src/test-setup.tssetsTABLE_NAME
Domain Implementation Pattern
When adding a new domain to core:
- Create
packages/core/src/domain/[name]/ - Implement in order:
entity.ts(ElectroDB) →schema.ts(Zod) →index.ts(operations) - Add tests alongside (
index.test.tsfor pure logic,entity.test.tsfor key shapes via.params()) - Export from
packages/core/src/index.tsas a namespace (export * as Name from './domain/name') - Add a tRPC router in
packages/functions/api/router/[name].tsand register it inrouter/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
- Never import server-only code (
@shippad/coremain entry, ElectroDB, AWS SDK) in route modules outside loaders/actions or*.server.tsfiles — React Router bundles route module top-level imports for the client. Browser-safe imports:@shippad/core/constants, the timezone date helpers via@shippad/core/utils/date(pure Intl, no AWS — do NOT use the@shippad/core/utilsbarrel, which pulls in AWS via rate-limit), and Zod schemas via@shippad/core/domain/[name]/schema. - Route types come from
react-router typegen(./+types/[route]); runpnpm typecheckto regenerate.