# Skills (http://page.easystarter.dev/docs/mobile/ai-prompts) Usage [#usage] Open your AI coding assistant in the EasyStarter project root, then enter a Skill name followed by your request: ```text $easystarter-mobile-quick-launch Launch my mobile app with the recommended setup. ``` Mobile [#mobile] | Task | Command | | ---------------------------- | ----------------------------------------------------------------------------------------------------- | | Quick launch | `$easystarter-mobile-quick-launch Launch my mobile app with the recommended setup.` | | Start with a simulator | `$easystarter-mobile-dev-simulator-server Start the mobile app and Server for simulator development.` | | Start with a physical device | `$easystarter-mobile-real-device-server Start physical-device development with Server.` | | Configure app identity | `$easystarter-mobile-app-config Configure app.json and the app identifiers.` | | Configure Cloudflare | `$easystarter-mobile-cloudflare Configure Cloudflare for the mobile Server.` | | Configure the database | `$easystarter-mobile-database Configure the D1 database.` | | Configure email | `$easystarter-mobile-resend-email Configure Resend email for mobile.` | | Configure authentication | `$easystarter-mobile-auth Configure mobile authentication.` | | Configure phone login | `$easystarter-mobile-aliyun-phone-login Configure Alibaba Cloud phone login.` | | Configure storage | `$easystarter-mobile-storage Configure mobile storage.` | | Create store products | `$easystarter-mobile-iap-products Create the in-app purchase products.` | | Configure RevenueCat | `$easystarter-mobile-revenuecat Configure RevenueCat.` | | Deploy Server | `$easystarter-mobile-deploy-server Deploy Server for the mobile app.` | | Build and submit | `$easystarter-mobile-submit-app Build and submit the app.` | | Customize the theme | `$easystarter-mobile-theme Customize the mobile theme.` | | Configure analytics | `$easystarter-mobile-analytics Configure mobile analytics.` | | Configure credits | `$easystarter-mobile-credits Configure the mobile credit system.` | Shared Development [#shared-development] | Task | Command | | -------------------- | ---------------------------------------------------------------- | | Add an API route | `$easystarter-api-route Create an API route for [feature].` | | Add a database table | `$easystarter-db-schema Create a database schema for [feature].` | | Add translations | `$easystarter-i18n Add translations for [feature].` | # Theme System (http://page.easystarter.dev/docs/mobile/config/theme) ## Theme System The app theme is built on two independent dimensions: **appearance mode** (light / dark / system) and **theme family**. Combined, they produce an active theme name that drives style rendering via [Uniwind](https://github.com/mazeincoding/Uniwind). --- ## Two Dimensions ### Appearance Mode (ThemeModePreference) Controls whether the app uses a light or dark color scheme: | Value | Description | | --- | --- | | `system` | Follow the device system setting (default) | | `light` | Force light mode | | `dark` | Force dark mode | ### Theme Family (ThemeFamily) Controls the overall color tone. Four families are built in: | Value | Style | | --- | --- | | `alpha` | Default family | | `lavender` | Soft purple | | `mint` | Fresh green | | `sky` | Clear blue | ### Active Theme Name The two dimensions combine into the active theme name: `{themeFamily}-{resolvedThemeMode}` For example, selecting `lavender` family + dark mode produces `lavender-dark`. This name is passed to `Uniwind.setTheme()` to switch component styles. --- ## Key Files | File | Description | | --- | --- | | `apps/native/providers/theme-provider.tsx` | Theme state, persistence, and Uniwind sync | | `apps/native/configs/app-config.ts` | Storage key names | --- ## User Preference Storage Theme choices are persisted to device storage via `AsyncStorage`: | Storage key (from `app-config.ts`) | Content | | --- | --- | | `{AppName}_theme_preference` | Appearance mode: `system` / `light` / `dark` | | `{AppName}_theme_family` | Theme family: `alpha` / `lavender` / `mint` / `sky` | `AppName` comes from the app name configured in `packages/app-config`. --- ## Changing the Default Theme When `ThemeProvider` initializes and `AsyncStorage` has no stored values, it uses the defaults defined in `useState`: ```typescript title="apps/native/providers/theme-provider.tsx" const [themeModePreference, setThemeModePreferenceState] = useState("system"); // default: follow system const [themeFamily, setThemeFamilyState] = useState("alpha"); // default: alpha ``` Change the initial values to set a different out-of-box default for new users. --- ## Adding a New Theme Family ### Add the new family to the type Edit `theme-provider.tsx` to extend `THEME_FAMILIES` and `ThemeFamily`: ```typescript title="apps/native/providers/theme-provider.tsx" const THEME_FAMILIES = ["alpha", "lavender", "mint", "sky", "ocean"] as const; // ↑ new export type ThemeFamily = "alpha" | "lavender" | "mint" | "sky" | "ocean"; ``` ### Register themes in Uniwind Follow the same pattern as the existing `alpha`, `lavender`, and other families: create a CSS file, import it in `global.css`, then register it in `metro.config.js`. **1. Create `apps/native/themes/ocean.css`** Modeled on `themes/alpha.css`, define both light and dark variants: ```css title="apps/native/themes/ocean.css" @layer theme { :root { @variant ocean-light { --radius: 0.5rem; --background: oklch(0.97 0.01 220); --foreground: oklch(0.15 0.03 220); --surface: oklch(0.97 0.01 220); --surface-foreground: var(--foreground); --surface-secondary: oklch(0.93 0.02 220); --surface-secondary-foreground: var(--foreground); --surface-tertiary: oklch(0.90 0.02 220); --surface-tertiary-foreground: var(--foreground); --overlay: oklch(0.97 0.01 220); --overlay-foreground: var(--foreground); --muted: var(--color-neutral-500); --default: oklch(0.92 0.02 220); --default-foreground: oklch(0.15 0.03 220); --accent: oklch(0.45 0.15 220); --accent-foreground: var(--snow); --field-background: var(--default); --field-foreground: var(--foreground); --field-placeholder: var(--muted); --field-border: transparent; --success: oklch(0.55 0.12 154); --success-foreground: var(--snow); --warning: oklch(0.72 0.15 65); --warning-foreground: var(--eclipse); --danger: oklch(0.63 0.19 29); --danger-foreground: var(--snow); --segment: oklch(0.97 0.01 220); --segment-foreground: var(--eclipse); --border: oklch(0.85 0.03 220); --separator: oklch(0.75 0.03 220); --focus: var(--accent); --link: var(--foreground); --surface-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.06); --overlay-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.02), 0 14px 28px 0 rgba(0, 0, 0, 0.03); --field-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.06); } @variant ocean-dark { --radius: 0.5rem; --background: oklch(0.12 0.03 220); --foreground: oklch(0.92 0.02 220); --surface: oklch(0.17 0.03 220); --surface-foreground: var(--foreground); --surface-secondary: oklch(0.22 0.03 220); --surface-secondary-foreground: var(--foreground); --surface-tertiary: oklch(0.25 0.03 220); --surface-tertiary-foreground: var(--foreground); --overlay: oklch(0.20 0.03 220); --overlay-foreground: var(--foreground); --muted: var(--color-neutral-400); --default: oklch(0.20 0.03 220); --default-foreground: var(--snow); --accent: oklch(0.65 0.15 220); --accent-foreground: var(--eclipse); --field-background: var(--default); --field-foreground: var(--foreground); --field-placeholder: var(--muted); --field-border: transparent; --success: oklch(0.55 0.12 154); --success-foreground: var(--snow); --warning: oklch(0.85 0.14 78); --warning-foreground: var(--eclipse); --danger: oklch(0.58 0.16 31); --danger-foreground: var(--snow); --segment: oklch(0.20 0.03 220); --segment-foreground: var(--foreground); --border: oklch(0.25 0.03 220); --separator: oklch(0.35 0.03 220); --focus: var(--accent); --link: var(--foreground); --surface-shadow: 0 0 0 0 transparent inset; --overlay-shadow: 0 0 1px 0 rgba(255, 255, 255, 0.2) inset; --field-shadow: 0 0 0 0 transparent inset; } } } ``` > Every theme must declare **exactly the same variable names**. Cross-check against `alpha.css` for the full list. **2. Import in `global.css`** ```css title="apps/native/global.css" @import "./themes/alpha.css"; @import "./themes/lavander.css"; @import "./themes/mint.css"; @import "./themes/sky.css"; @import "./themes/ocean.css"; /* add this */ ``` **3. Register in `metro.config.js`** ```js title="apps/native/metro.config.js" module.exports = withUniwindConfig(config, { cssEntryFile: "./global.css", dtsFile: "./uniwind-types.d.ts", extraThemes: [ "alpha-light", "alpha-dark", "lavender-light", "lavender-dark", "mint-light", "mint-dark", "sky-light", "sky-dark", "ocean-light", "ocean-dark", // add these ], }); ``` After editing `metro.config.js`, **restart Metro**. If you see stale styles, run `npx expo start --clear`. Reference: [Uniwind custom themes docs](https://docs.uniwind.dev/theming/custom-themes) ### Add i18n translations (optional) Add display names and descriptions for the new family in the locale message files: ```json title="packages/i18n/messages/native/en.json" { "settings": { "themeFamilyOptions": { "ocean": "Ocean" }, "themeFamilyDescriptions": { "ocean": "Deep ocean blues" } } } ``` # Create a Project with CLI (http://page.easystarter.dev/docs/mobile/create-project) After you purchase EasyStarter and accept the GitHub collaborator invite, use this command to create **your** project. You do not clone the template by hand. The template repository is private. Accept the collaborator invite first. If the command cannot download the template, the invite is still pending. ### Install tools - [`Node.js 22+`](https://nodejs.org/) - [`pnpm 9+`](https://pnpm.io/) - [`git`](https://git-scm.com/) ### Create the project In an empty directory: ```bash pnpm create easystarter my-app ``` or: ```bash npx create-easystarter my-app ``` Replace `my-app` with your project name. It must be lowercase kebab-case (`acme-app`, not `Acme App`). The command downloads EasyStarter, names the project after your choice, writes local env files, installs dependencies, and can start the dev servers. ### Answer the setup questions The wizard asks about **your product**. Pick what you need now — you can change these later in `packages/app-config`. | Question | What to choose | |---|---| | App display name | The name users see, for example `Acme` | | Auth methods | At least one: `email-password`, `email-otp`, `github`, `google`, `apple`, `sms` | | Web payments | `none`, `stripe`, `creem`, or `waffo` | | Native payments | `none` or `revenuecat` | | Email provider | `cloudflare` or `resend` | | Enable credits | Yes only if you sell credits | | Install dependencies | Yes | | Initialize git | Yes | Default choices are email/password login, no payments, Cloudflare email, and no credits. After that it migrates the local database and starts Web + Server. Skip the questions and use those defaults: ```bash pnpm create easystarter my-app -y ``` Pass flags if you already know the stack, for example: ```bash pnpm create easystarter my-app \ --app-name "Acme" \ --auth email-password github \ --payments stripe \ --native-payments revenuecat \ --email resend \ --no-dev ``` `--no-dev` creates the project without starting the servers. `--auth` accepts one or more methods. ### Open the local app When create finishes (and you did not pass `--no-dev`): | App | URL | |---|---| | Web | [http://localhost:3000](http://localhost:3000) | | Server | [http://localhost:3001](http://localhost:3001) | | Extension | [http://localhost:3002](http://localhost:3002) | If the servers are not running: ```bash cd my-app pnpm dev:web+server ``` Env files are already created. Do not copy the `.example` files over them — that overwrites `BETTER_AUTH_SECRET`. If you enabled GitHub, Google, Apple, SMS, Resend, Stripe, Creem, Waffo, or RevenueCat, the command prints the extra keys those features still need. Add them before you use those integrations. ### Connect Cloudflare Create only sets up the local project. It does **not** create Cloudflare D1 or R2, and it does **not** deploy. When you are ready to attach your Cloudflare account (needed for remote database, object storage, and deploy): ```bash cd my-app pnpm exec create-easystarter init ``` or: ```bash npx create-easystarter init ``` The command opens a Cloudflare login if needed, then creates or reuses `{project}-db` and `{project}-bucket`, and writes the IDs into your project. Local development can run before this step. To also apply remote D1 migrations: ```bash pnpm exec create-easystarter init --migrate ``` Remote migrate needs a Cloudflare API token with D1 edit permission. Create one at [API Tokens](https://dash.cloudflare.com/profile/api-tokens). You can skip the token during `init` and add `CLOUDFLARE_API_TOKEN` later, then run `pnpm db:migrate`. You can also pass production URLs: ```bash pnpm exec create-easystarter init \ --website-url https://example.com \ --server-url https://api.example.com ``` # Mobile Data Access (http://page.easystarter.dev/docs/mobile/database) ## Database The project uses [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) as its database layer. ### Create the D1 database See official docs: [D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) Option 1: Cloudflare Dashboard 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **Storage & databases → D1 SQL database** 3. Click **Create database** 4. Enter a database name, for example `easysaas-db` 5. Choose a location if needed 6. Click **Create** Once created, copy the `database_id` from the database details page. Option 2: Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` On success, Wrangler outputs a D1 binding snippet that contains the `database_id`. ### Configure the D1 database ID After obtaining your `database_id`, add it to the following two locations. Environment variables: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` For how to obtain `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, see [Cloudflare Integration](/docs/web/integrations/cloudflare). Set `database_id` in: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` and: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler config: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` That means `apps/server/wrangler.jsonc` must use the same `database_id`. ### Run the local database workflow For local database development, use these three commands in this order: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` Generate migration files from `apps/server/src/db/schema`. `pnpm db:migrate:local` Apply the generated migrations to the local D1 database. This command already handles local D1 initialization. `pnpm db:studio:local` Open the local D1 visual UI so you can inspect tables and data. # Submit to App Stores (http://page.easystarter.dev/docs/mobile/deploy/deploy-app) ## Submit to App Stores EasyStarter's mobile app uses [EAS (Expo Application Services)](https://expo.dev/eas) for cloud builds and app store submissions. EAS handles code signing, bundling, and automated submission — no local certificate setup required. **Before submitting, make sure the [Server is deployed](/docs/mobile/deploy/deploy-server) and RevenueCat and auth callbacks are correctly configured.** ## Prerequisites | Platform | Required | | --- | --- | | **iOS** | Apple Developer account ($99/year), App created in App Store Connect | | **Android** | Google Play Console account ($25 one-time), App created in Google Play | | **General** | [EAS CLI](https://docs.expo.dev/eas-build/setup/) installed, logged into your Expo account | ```bash npm install -g eas-cli eas login ``` ## Update `eas.json` config Before building, update the production profile's environment variables to point to your live URLs: If the local production env file doesn't exist yet, copy the template first: ```bash cp apps/native/.env.production.example apps/native/.env.production ``` Then fill `apps/native/.env.production` with the same production `EXPO_PUBLIC_` variables. EAS cloud builds use the `env` field in `eas.json`; `.env.production` is mainly for local production builds or exports. ```jsonc title="apps/native/eas.json" { "build": { "production": { "autoIncrement": true, "channel": "production", "environment": "production", "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", // Server production URL "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", // Web production URL "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } } } } ``` ## Update `app.json` identifiers Confirm the bundle identifier and package name in `apps/native/app.json` match your app store accounts: ```jsonc title="apps/native/app.json" { "expo": { "name": "Your App Name", "slug": "your-app-slug", "version": "1.0.0", "ios": { "bundleIdentifier": "com.yourcompany.yourapp", // Must match App Store Connect "appleTeamId": "YOUR_TEAM_ID" // Found in your Apple Developer account }, "android": { "package": "com.yourcompany.yourapp" // Must match Google Play package name }, "extra": { "eas": { "projectId": "your-eas-project-id" // Auto-generated by eas init } } } } ``` If you haven't initialized the EAS project yet: ```bash cd apps/native eas init ``` ### Build the iOS production binary ```bash pnpm -F native eas:build:ios:production ``` Equivalent to `eas build --platform ios --profile production`. EAS builds the `.ipa` file in the cloud. On first build, EAS will guide you through: - Creating or reusing an Apple Distribution Certificate - Creating or reusing a Provisioning Profile Check build status and download artifacts on the [Expo Dashboard](https://expo.dev). > For a local build (requires macOS + Xcode), use `eas:build:ios:production:local`. ### Build the Android production binary ```bash pnpm -F native eas:build:android:production ``` Equivalent to `eas build --platform android --profile production`. EAS builds an `.aab` file (Google Play's recommended format). On first build, EAS will prompt you to upload or auto-generate an Android Keystore. **Keep the Keystore safe** — you must use the same Keystore to sign all future updates. ### Submit to the App Store ```bash pnpm -F native eas:submit:ios:production ``` Equivalent to `eas submit --platform ios --profile production`. EAS automatically uploads the build artifact to App Store Connect. After upload: 1. Sign in to [App Store Connect](https://appstoreconnect.apple.com) 2. Go to your App → **TestFlight** to verify the build 3. Switch to the **App Store** tab and create a new version 4. Fill in release notes, screenshots, keywords, and other metadata 5. Submit for review (typically 1–3 business days) ### Submit to Google Play ```bash pnpm -F native eas:submit:android:production ``` Equivalent to `eas submit --platform android --profile production`. After upload: 1. Sign in to [Google Play Console](https://play.google.com/console) 2. Go to your App → **Release → Production** 3. Review the new build upload and fill in release notes 4. Submit for review (typically a few hours to a few days) ## OTA updates (no app store review required) EasyStarter integrates [Expo Updates](https://docs.expo.dev/eas-update/introduction/), allowing you to push JavaScript-layer changes to installed apps without going through the app store review process. Suitable for bug fixes, UI adjustments, and copy changes that don't touch native code: ```bash # Push to the production channel pnpm -F native eas:update:production ``` > OTA updates can only update JavaScript/TypeScript code and static assets. They cannot update native modules (e.g. adding Expo plugins, modifying native fields in `app.json`). Native changes still require a full build and store submission. ## Version management `"autoIncrement": true` in `eas.json` automatically increments the Build Number (iOS) and Version Code (Android) on every build — no manual changes to `app.json` needed. | Field | Description | | --- | --- | | `version` (in `app.json`) | User-visible version string, e.g. `1.2.0` — update manually | | Build Number / Version Code | Internal store version — managed automatically by `autoIncrement` | | `runtimeVersion` | Controls OTA compatibility — defaults to `appVersion` policy | # Deploy Server (http://page.easystarter.dev/docs/mobile/deploy/deploy-server) ## Deploy Server (Cloudflare Workers) EasyStarter's server is built with [Hono](https://hono.dev/) and runs on [Cloudflare Workers](https://workers.cloudflare.com/), using D1 as the database and R2 as object storage. Before starting, confirm that the following prerequisites are in place: - Cloudflare credentials ready (see [Cloudflare Integration](/docs/web/integrations/cloudflare)) - D1 database created and **Database ID** on hand (see [Database](/docs/web/integrations/database)) - R2 bucket created and **bucket name** on hand (see [Storage](/docs/web/integrations/storage)) EasyStarter supports two deployment methods — choose the one that fits your workflow: | Method | Best for | | --- | --- | | **Option 1: Local CLI** | Quick launch, one-off deploys, full manual control | | **Option 2: GitHub auto-deploy** | Continuous delivery, team collaboration, deploy on push | --- ## Option 1: Local CLI deploy Authenticate Wrangler locally, then run the deploy commands manually. ```bash npx wrangler login ``` ## Environment variable overview Server variables live in three separate places: | Type | Location | Description | | --- | --- | --- | | **Public config** | `apps/server/wrangler.jsonc` → `vars` | Non-sensitive values — stored in plain text, deployed with code | | **Local dev** | `apps/server/.dev.vars` | Auto-loaded by `wrangler dev`, never deployed | | **Production secrets** | `apps/server/.env.production` | Pushed to Workers Secrets via `wrangler secret bulk`, never in build output | > Never commit `.env.production` to Git. Add `.dev.vars` to `.gitignore` as well. ### Update `apps/server/wrangler.jsonc` Fill in your Worker name, D1 Database ID, R2 bucket name, and public variables: ```jsonc title="apps/server/wrangler.jsonc" { "name": "your-server-worker", // Worker name — globally unique, determines default URL "main": "src/index.ts", "compatibility_date": "2025-06-15", "compatibility_flags": ["nodejs_compat"], "d1_databases": [ { "binding": "DB", "database_name": "your-db-name", // D1 database name (arbitrary, for your reference) "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // D1 Database ID (UUID) } ], "vars": { "NODE_ENV": "production", "WEBSITE_URL": "https://your-app.com", // Public URL of the web app "SERVER_URL": "https://your-server.workers.dev", // Public URL of this server Worker "GITHUB_CLIENT_ID": "your-github-client-id", // Public — safe to put in vars "GOOGLE_CLIENT_ID": "your-google-client-id" // Public — safe to put in vars }, "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-bucket-name" // R2 bucket name } ] } ``` | Field | Description | | --- | --- | | `name` | Worker name — default URL is `https://..workers.dev` | | `database_id` | UUID of the D1 database | | `vars.WEBSITE_URL` | Web app URL — used by Better Auth for callback URLs and email links | | `vars.SERVER_URL` | This server Worker's URL — used by Better Auth config and CORS | | `bucket_name` | Must match the R2 bucket name in the Cloudflare Dashboard | ### Prepare production secrets (`.env.production`) Copy the production env template first (if the file doesn't exist yet): ```bash cp apps/server/.env.production.example apps/server/.env.production ``` Then fill `apps/server/.env.production` with all sensitive variables. This file is never included in the build — it is only used by `wrangler secret bulk` in the next step. ```bash title="apps/server/.env.production" // Example environment variables: (Refer to apps/server/.env.production.example for actual content) BETTER_AUTH_SECRET=your-better-auth-secret GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret R2_PUBLIC_URL=https://your-bucket.your-subdomain.r2.dev REVENUECAT_API_KEY=your-revenuecat-api-key STRIPE_SECRET_KEY=your-stripe-secret-key STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret ``` **Key notes:** - Variables for unused integrations (e.g. RevenueCat) can be left empty or removed ### Deploy the Worker ```bash pnpm deploy:server ``` This compiles `apps/server/src/index.ts` and publishes it to Cloudflare Workers. On success: ``` Deployed your-server-worker triggers: https://your-server-worker.your-subdomain.workers.dev ``` Save this URL — you'll need it when configuring the web app and updating `SERVER_URL`. ### Push secrets Encrypt and store all variables from `.env.production` in Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` This runs `wrangler secret bulk .env.production`. Each value is encrypted at rest on Cloudflare's side and never appears in deployed code or logs. > Secrets and code deployments are independent. To update a sensitive variable, re-run this command — no redeploy needed. ### Run database migrations Apply the database schema to Cloudflare D1. The `pnpm db:migrate` command uses drizzle-kit's D1 HTTP driver, which requires these three values in `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= # Your Cloudflare account ID CLOUDFLARE_API_TOKEN= # API token with D1 Edit permission CLOUDFLARE_D1_DATABASE_ID= # D1 database UUID ``` Then run: ```bash pnpm db:migrate ``` This creates all required tables in the production D1 database (users, sessions, subscriptions, billing, etc.). > After every schema change: run `pnpm db:generate` to produce a new migration file, then `pnpm db:migrate` to apply it to production. ## Verify the deployment In the Cloudflare Dashboard, go to **Workers & Pages** → select your Worker → **Logs** to view live request logs and confirm the service is responding correctly. --- ## Option 2: GitHub auto-deploy Connect your GitHub repository to Cloudflare so every push to the target branch automatically triggers a build and deploy — no local commands needed. ### Connect your GitHub repository 1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. Click **Create** → **Workers** → **Connect to Git** 3. Authorize Cloudflare to access your GitHub account and select your repository 4. Choose the deployment branch (usually `master`) ### Push secrets After connecting the repository but before the first build fires, push all secrets to Cloudflare from your local machine so the Worker has everything it needs on startup: ```bash pnpm -F server secrets:bulk:production ``` This runs `wrangler secret bulk .env.production`, encrypting every variable in `apps/server/.env.production` into Worker Secrets. > Secrets and code deploys are independent. You only need to re-push when a secret value changes — not on every code update. ### Enter the build configuration Fill in the following settings on the build configuration page: | Field | Value | | --- | --- | | **Root directory** | `/` | | **Build command** | `pnpm --filter server build` | | **Deploy command** | `pnpm --filter server run deploy` | | **Version command** | `pnpm --filter server run deploy` | > Root directory is `/` because this is a monorepo — pnpm workspaces must resolve dependencies from the repo root. ### Disable non-production branch deployments After saving the build configuration, **disable non-production branch builds**. This step is mandatory. The server Worker uses a fixed name — if Cloudflare builds and deploys a non-production branch (e.g. `feature/x`), it overwrites the same Worker, pointing production traffic at unfinished code and potentially running unintended database migrations against the live D1 database. ### Run database migrations Auto-deploy does **not** run database migrations automatically. After the first deploy, run this manually from your local machine: ```bash pnpm db:migrate ``` Make sure `apps/server/.dev.vars` has these values filled in: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` > For every future schema change: run `pnpm db:generate` locally to generate a migration file, then `pnpm db:migrate` to apply it to the production D1. Once configured, every push to the target branch automatically triggers a new build and deployment. View the status and logs for each deploy under **Workers & Pages → your Worker → Deployments**. --- ## Custom domain (Recommended) 1. Go to **Workers & Pages** → select your server Worker → **Settings → Domains & Routes** 2. Click **Add Custom Domain** and enter a domain hosted on Cloudflare (e.g. `api.yourdomain.com`) 3. Once the domain is bound, update the related config as described below ### Why these two fields must be updated `SERVER_URL` and `WEBSITE_URL` are not ordinary environment variables — they live in the `vars` block of `wrangler.jsonc` and are **compiled into the Worker bundle** at deploy time. Changing them requires a redeploy to take effect. These two fields are critical for the authentication system: | Field | Used for | | --- | --- | | `SERVER_URL` | Better Auth `baseURL`; OAuth callback URLs (`/api/auth/callback/github`, etc.); cookie `domain` and `secure` policy | | `WEBSITE_URL` | Better Auth `trustedOrigins` (CORS allowlist); redirect links in transactional emails | If either value doesn't match the actual domain, OAuth callbacks will return 404, cross-origin requests will be blocked by CORS, and session cookies won't be set. ### Files to update **① `apps/server/wrangler.jsonc`** ```jsonc "vars": { "WEBSITE_URL": "https://your-app.com", // final domain of the web app "SERVER_URL": "https://api.yourdomain.com" // the custom domain you just bound } ``` **② `apps/web/wrangler.jsonc`** The web app also holds the server address for direct API calls from the frontend: ```jsonc "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com" // must match SERVER_URL above } ``` **③ OAuth app settings** If you changed `SERVER_URL`, update the callback URLs in each OAuth provider: - **GitHub**: Settings → Developer settings → OAuth Apps → update **Authorization callback URL** to `https://api.yourdomain.com/api/auth/callback/github` - **Google**: Google Cloud Console → Credentials → OAuth 2.0 Client → update **Authorized redirect URIs** ### Redeploy to apply the changes Both apps have changes, so deploy both: ```bash pnpm deploy:server pnpm deploy:web ``` > `vars` are static config compiled into the bundle — they are not Secrets. Any change to `vars` in `wrangler.jsonc` requires a redeploy. Running `wrangler secret bulk` alone will not update these values. # Mobile Getting Started (http://page.easystarter.dev/docs/mobile/getting-started) ## Shared Setup Before starting either client, finish the common workspace setup first: ### Install Prerequisites Ensure your development environment has the necessary tools installed: - Install [`Node.js 20+`](https://nodejs.org/) - Install [`pnpm 9+`](https://pnpm.io/) - Install [`git`](https://git-scm.com/) ### Clone Repository Clone the repository and enter the project root to begin development: ```bash # clone repository git clone https://github.com/sunshineLixun/easystarter.git your-project-name # enter project root cd your-project-name # remove default origin git remote remove origin # add your own origin git remote add origin https://github.com/your-username/your-project-name.git # push to origin git push -u origin main ``` ### Install Dependencies Run the following command to download and install all necessary project dependencies: ```bash pnpm install ``` {props.children} # Mobile Overview (http://page.easystarter.dev/docs/mobile) Mobile [#mobile] EasyStarter's mobile app is built with React Native and Expo, located in `apps/native`. It shares authentication logic, API contracts, configuration, and i18n resources with the web app — but has its own screen structure, native capabilities, and payment stack. The mobile app uses RevenueCat to manage iOS and Android subscriptions and in-app purchases. Authentication is handled by Better Auth with deep-link support, including native Apple Sign-In. The UI layer is built on HeroUI Native with a Tailwind-style system (Uniwind). Tech Stack [#tech-stack] | Layer | Technology | | -------------- | ----------------------------------------------------------------------------------------------------------------- | | Framework | [React Native](https://reactnative.dev/) + [Expo](https://expo.dev/) | | Routing | [Expo Router](https://docs.expo.dev/router/introduction/) (file-based) | | UI | [HeroUI Native](https://heroui-native.com/) + [Uniwind](https://uniwind.dev/) (Tailwind v4) | | API | [oRPC](https://orpc.dev/) + [TanStack Query](https://tanstack.com/query) | | Auth | [Better Auth](https://better-auth.com/) + @better-auth/expo | | Payments | [RevenueCat](https://www.revenuecat.com/) (iOS/Android subscriptions & IAP) | | Build & Submit | [EAS Build](https://docs.expo.dev/build/introduction/) + [EAS Submit](https://docs.expo.dev/submit/introduction/) | | i18n | @repo/i18n | Recommended Reading Order [#recommended-reading-order] **New to the project:** 1. [Getting Started](/docs/mobile/getting-started) — Run the Expo app locally 2. [Project Structure](/docs/mobile/project-structure) — Understand the directory layout and key files **Integrating external services:** 3. [Cloudflare](/docs/mobile/integrations/cloudflare) — Account credentials, foundation for all services 4. [Database](/docs/mobile/integrations/database) — D1 configuration 5. [Authentication](/docs/mobile/integrations/authentication) — Better Auth + OAuth setup 6. [RevenueCat](/docs/mobile/integrations/iap/revenuecat) — Mobile subscriptions and in-app purchases **Getting ready to ship:** 7. [Deploy Server](/docs/mobile/deploy/deploy-server) — Deploy the backend first 8. [Publish App](/docs/mobile/deploy/deploy-app) — EAS Build + App Store / Google Play submission Looking for Web docs? [#looking-for-web-docs] Switch to the [Web documentation](/docs/web). # Analytics (http://page.easystarter.dev/docs/mobile/integrations/analytics) ## Analytics The mobile app uses [OpenPanel](https://openpanel.dev/) for analytics — open-source, privacy-friendly, self-hostable. When no Client ID or Client Secret is set, the SDK skips initialization entirely. The mobile template does not listen to every route change by default. Track only key funnel steps such as onboarding completed, login succeeded, subscription started, core generation finished, and submission created. This keeps the data useful and avoids spending free-tier usage on low-signal navigation events. ### Create an OpenPanel project Official docs: [React Native SDK](https://openpanel.dev/docs/sdks/react-native) 1. Sign up at [openpanel.dev](https://openpanel.dev/) 2. In the dashboard click **Create Project** 3. Fill in **Project name** (feel free to reuse the same project as Web for cross-platform funnels) 4. Enable **App**, and turn off **Website** and **Backend / API** unless you need them now 5. Click **Create project** 6. After creation, copy the App **Client ID** and **Client Secret** from the project's client details ### Set environment variables **Local development** (`apps/native/.env.development.local`): Loaded by `expo start` in dev mode. Stays on your machine — never bundled into a release artifact: ```bash title="apps/native/.env.development.local" EXPO_PUBLIC_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET=xxxxxxxx-client-secret ``` **Local production builds** (`apps/native/.env.production`): When you build in production mode locally — e.g. `eas build --local --profile production`, a native build after `expo prebuild`, or `expo export` for an OTA bundle — Expo loads this file automatically based on `NODE_ENV=production`. Start by copying the example: ```bash cp apps/native/.env.production.example apps/native/.env.production ``` Then fill in the Client ID and Client Secret: ```bash title="apps/native/.env.production" EXPO_PUBLIC_OPENPANEL_CLIENT_ID=xxxxxxxx-prod-client-id EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET=xxxxxxxx-prod-client-secret ``` > `.env.production` is gitignored and stays local. For EAS cloud builds, **`eas.json`'s `env` block takes precedence and overrides any variable defined in this file** — cloud builds only need the configuration below. **Cloud builds** (`env` block in `apps/native/eas.json`): `eas.json` defines three build profiles (`development`, `preview`, `production`), each with its own `env` block. Fill in a Client ID and Client Secret per profile. If you want to keep production data separate from internal builds, create different Clients in the OpenPanel dashboard for each environment rather than reusing one — otherwise events from QA builds and real users land in the same project. ```jsonc title="apps/native/eas.json" { "build": { "development": { "developmentClient": true, "distribution": "internal", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-dev-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-dev-client-secret" } }, "preview": { "distribution": "internal", "channel": "preview", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-preview-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-preview-client-secret" } }, "production": { "autoIncrement": true, "channel": "production", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-prod-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-prod-client-secret" } } } } ``` ### Track key events explicitly The native app exposes explicit tracking helpers. Call them only at meaningful product milestones: ```ts import { trackOpenPanelEvent } from "@/lib/analytics/openpanel"; trackOpenPanelEvent("onboarding_completed", { source: "welcome_flow", }); ``` If a screen is part of the funnel, record a screen view manually: ```ts import { trackOpenPanelScreenView } from "@/lib/analytics/openpanel"; trackOpenPanelScreenView("/paywall"); ``` Do not listen to every route change by default. Start with 3-5 key funnel steps, then add events only when they answer a real product question. # Configure app.config.ts (http://page.easystarter.dev/docs/mobile/integrations/app-json) ## Configure app.config.ts `apps/native/app.config.ts` is the core configuration file for your Expo app. **The very first thing** you should do after cloning the template is replace all the EasyStarter-specific identifiers with your own. > For a full list of supported fields, see the [Expo app configuration reference](https://docs.expo.dev/workflow/configuration/) --- ## Step 1: Choose and register your app identifiers Before editing `app.config.ts`, decide on your identifiers and register them in the Apple Developer Portal. ### Identifier format Both iOS and Android use **reverse domain notation**: ``` com.yourcompany.yourapp ``` It's recommended to keep the iOS `bundleIdentifier` and Android `package` in sync — same value, easier to manage. ### Create an App ID in Apple Developer Portal The iOS `bundleIdentifier` is not just a string you pick freely — it must be registered as an App ID in the Apple Developer Portal before you can use App Store distribution or native capabilities like Sign In with Apple. 1. Log in to [Apple Developer Portal → Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Click `+` → select `App IDs` → type `App` → Continue 3. Enter a **Description** (your app name) 4. Enter your **Bundle ID** (e.g. `com.yourcompany.yourapp`), select `Explicit` 5. Under Capabilities, check **Sign In with Apple** 6. Click Continue → Register Once registered, fill in this Bundle ID in two places: ```ts title="apps/native/app.config.ts" const baseConfig: ExpoConfig = { ios: { bundleIdentifier: "com.yourcompany.yourapp", }, }; ``` ```bash title="apps/server/wrangler.jsonc → vars" APPLE_APP_BUNDLE_IDENTIFIER=com.yourcompany.yourapp ``` ### Android package name Android doesn't require pre-registration. Just pick a package name in the correct format in `app.config.ts`. You'll register it in Google Play Console when you publish. --- ## Fields you must replace The following fields directly affect your app's identity, deep links, and App Store / Google Play submission. **Replace them before you start development.** All of these fields belong in the `baseConfig` object in `app.config.ts`. ### `name` The display name shown on the home screen and in system settings. ```ts name: "My App", ``` ### `slug` The URL-safe identifier used in Expo's services. Must be globally unique and can only contain letters, numbers, and hyphens. ```ts slug: "my-app", ``` ### `scheme` The deep link URL scheme used for OAuth callbacks and app-to-app navigation. Keep it consistent with your `slug` to avoid conflicts with other apps. ```ts scheme: "my-app", ``` > Better Auth's mobile OAuth callback (Google Sign-In) depends on this scheme. ### `ios.bundleIdentifier` The unique iOS app identifier. Must exactly match the App ID you created in the Apple Developer Portal. ```ts ios: { bundleIdentifier: "com.yourcompany.yourapp", } ``` > This is also the value used for `APPLE_APP_BUNDLE_IDENTIFIER`. ### `ios.appleTeamId` Your Apple Developer Team ID. Find it in [Apple Developer Portal → Membership](https://developer.apple.com/account). ```ts ios: { appleTeamId: "XXXXXXXXXX", } ``` ### `android.package` The Android app package name. Must match the package name registered in Google Play Console. Use reverse domain format. ```ts android: { package: "com.yourcompany.yourapp", } ``` ### `extra.eas.projectId` and `updates.url` These fields bind the app to your EAS project. If you cloned the template directly, run: ```bash cd apps/native eas init ``` `app.config.ts` is dynamic, so EAS CLI cannot update it automatically. After the command finishes, manually assign the reported project ID to `easProjectId`; `updates.url` is derived from that constant. --- ## Icons and splash screen Replace the image assets at the following paths with your own brand assets: | Field | Path | Description | |-------|------|-------------| | `icon` | `./assets/images/icon.png` | Universal icon (1024×1024 PNG) | | `ios.icon.light` | `./assets/images/icon.png` | iOS light mode icon | | `ios.icon.dark` | `./assets/images/icon.png` | iOS dark mode icon | | `android.adaptiveIcon.foregroundImage` | `./assets/images/android-icon-foreground.png` | Android adaptive icon foreground | | `android.adaptiveIcon.backgroundImage` | `./assets/images/android-icon-background.png` | Android adaptive icon background | | `android.adaptiveIcon.monochromeImage` | `./assets/images/android-icon-monochrome.png` | Android monochrome icon | | `plugins[expo-splash-screen].image` | `./assets/images/icon.png` | Splash screen image | --- ## Full configuration reference ```ts title="apps/native/app.config.ts" import type { ExpoConfig } from "expo/config"; const appVersion = "1.0.0"; const easProjectId = "your-eas-project-id"; const baseConfig: ExpoConfig = { name: "My App", slug: "my-app", version: appVersion, orientation: "portrait", icon: "./assets/images/icon.png", scheme: "my-app", userInterfaceStyle: "automatic", ios: { appleTeamId: "YOUR_TEAM_ID", buildNumber: "1.0.0", bundleIdentifier: "com.yourcompany.yourapp", usesAppleSignIn: true, icon: { dark: "./assets/images/icon.png", light: "./assets/images/icon.png", }, infoPlist: { ITSAppUsesNonExemptEncryption: false, }, }, android: { adaptiveIcon: { backgroundColor: "#E6F4FE", foregroundImage: "./assets/images/android-icon-foreground.png", backgroundImage: "./assets/images/android-icon-background.png", monochromeImage: "./assets/images/android-icon-monochrome.png", }, predictiveBackGestureEnabled: false, permissions: ["android.permission.RECORD_AUDIO"], package: "com.yourcompany.yourapp", }, extra: { router: {}, eas: { projectId: easProjectId, }, }, updates: { url: `https://u.expo.dev/${easProjectId}`, }, }; export default (): ExpoConfig => ({ ...baseConfig, runtimeVersion: appVersion, }); ``` # Alibaba Cloud Phone Sign-In (Recommended for China) (http://page.easystarter.dev/docs/mobile/integrations/authentication/aliyun-phone-auth) ## Alibaba Cloud Phone Sign-In EasyStarter ships with phone sign-in powered by the [Better Auth phone-number plugin](https://www.better-auth.com/docs/plugins/phone-number). The server sends and verifies SMS codes through Alibaba Cloud Dypnsapi, while the clients keep using Better Auth's `phoneNumber.sendOtp` and `phoneNumber.verify` APIs. If your product is deployed primarily in mainland China, phone sign-in should be the preferred, and often the only, sign-in method. SMS-code sign-in is the most familiar flow for local users, while GitHub, Google, Apple, and similar OAuth providers add extra availability, account-coverage, and compliance friction in China. Email/password can stay if your product needs it, but it is not required. For a China-first deployment, configuring Alibaba Cloud phone sign-in alone is enough; the other sign-in methods can remain disabled and unconfigured. The built-in flow currently supports mainland China phone numbers only: | Item | Current setup | | --- | --- | | Phone format | `+86` E.164 format, for example `+8613800138000` | | Send API | `SendSmsVerifyCode` | | Verify API | `CheckSmsVerifyCode` | | Server provider | `apps/server/src/sms/providers/aliyun.ts` | | Better Auth config | `apps/server/src/lib/auth.ts` | ## Required Environment Variables ```bash ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= ``` These credentials are used by the server to call Alibaba Cloud OpenAPI. Do not commit them and do not expose them to frontend environment variables. If you only keep phone sign-in, OAuth variables such as `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, and `GOOGLE_CLIENT_SECRET` do not need to be configured. In production, keep the base Better Auth session variables plus the Alibaba Cloud AccessKey variables in this guide. ### Enable Phone Number Verification Service First, make sure your Alibaba Cloud account has enabled Phone Number Verification Service and can call the Dypnsapi SMS verification APIs. Official API doc: [SendSmsVerifyCode](https://api.aliyun.com/document/Dypnsapi/2017-05-25/SendSmsVerifyCode) Alibaba Cloud documents `SendSmsVerifyCode` as the SMS verification-code sending API under Dypnsapi. It uses API version `2017-05-25`, and the permission action is `dypns:SendSmsVerifyCode`. ### Create a RAM User Use a RAM user AccessKey instead of an Alibaba Cloud root account AccessKey. 1. Log in to the [Alibaba Cloud RAM Console](https://ram.console.aliyun.com/) 2. Go to **Identities** → **Users** 3. Click **Create User** 4. Fill in the required information 5. In access configuration, select **Use permanent AccessKey to access** 6. After the user is created, the console returns to the user list automatically ### Get AccessKey ID and AccessKey Secret 1. In the user list, find the RAM user you just created. The AccessKey column shows the AccessKey ID and AccessKey Secret; click to copy them. `AccessKey ID` and `AccessKey Secret` are only shown once when they are created. If you lose them, disable the old key and create a new AccessKey. ### Grant the RAM User Permission to Call Dypnsapi 1. In the user list, find the RAM user you just created, then click the `Logon Name / Display Name` to open the user detail page 2. Click **Permissions** → **Grant Permission** 3. In the **Policy** step, search for `dypns`, find **AliyunDypnsReadOnlyAccess** and **AliyunDypnsFullAccess**, then select them You can also choose **PowerUserAccess**. It provides full access to Alibaba Cloud services and resources, including SMS, OSS, and other services. For least privilege, grant only **AliyunDypnsReadOnlyAccess** and **AliyunDypnsFullAccess**. They include all permissions for Phone Number Verification Service without granting access to unrelated services. 4. Confirm the authorization ### Set Local and Production Environment Variables For local development, add them to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` For production, add them to `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` `.env.production` is only used to bulk-push Cloudflare Workers Secrets. It does not participate in frontend builds and should not be committed. ### Push Production Secrets Before deploying to Cloudflare Workers, push the production credentials to Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` After this succeeds, the Worker runtime can read `env.ALIBABA_CLOUD_ACCESS_KEY_ID` and `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`. Re-run the same command whenever you rotate the credentials. ### Keep the Alibaba Cloud Verification Parameters Unchanged The built-in provider locks these parameters according to Alibaba Cloud's `SendSmsVerifyCode` documentation: ```ts title="apps/server/src/sms/providers/aliyun.ts" const ALIYUN_SMS_VERSION = "2017-05-25"; const ALIYUN_SMS_SIGN_NAME = "速通互联验证码"; const ALIYUN_SMS_TEMPLATE_CODE = "100001"; ``` Do not change these values: | Constant | Alibaba Cloud parameter | Why it must stay unchanged | | --- | --- | --- | | `ALIYUN_SMS_VERSION` | OpenAPI version | Dypnsapi's `SendSmsVerifyCode` API version is `2017-05-25` | | `ALIYUN_SMS_SIGN_NAME` | `SignName` | The API documentation uses the bundled Phone Number Verification sign name `速通互联验证码`; this API does not support ordinary custom SMS signs | | `ALIYUN_SMS_TEMPLATE_CODE` | `TemplateCode` | The bundled sign must be used with the bundled template, whose code is `100001` | This integration uses Dypnsapi's SMS verification API, not the ordinary Dysmsapi `SendSms` API. Do not replace the template code with an `SMS_...` code from the ordinary SMS service. ### Verify Phone Sign-In Locally Start the server and web app, then choose phone sign-in on the login page: ```bash pnpm dev:server pnpm dev:web ``` When the user requests a code, the frontend calls: ```bash POST /api/auth/phone-number/send-otp ``` When the user submits the code, it calls: ```bash POST /api/auth/phone-number/verify ``` The server converts the `+86` E.164 number into `CountryCode=86` and the local phone number required by Alibaba Cloud. Alibaba Cloud then generates, sends, and verifies the code. ## Common Questions ### Why not generate the code ourselves? The current implementation uses `TemplateParam={"code":"##code##","min":"5"}`, so Alibaba Cloud generates the code. This lets the server verify the submitted code through `CheckSmsVerifyCode` without storing OTP state itself. ### Why can't I use my own SMS sign? `SendSmsVerifyCode` belongs to Phone Number Verification Service. Alibaba Cloud's documentation says the bundled sign must be used with the bundled template, and ordinary custom signs are not supported by this API. The built-in values match the official example. ### What should I do if the AccessKey leaks? Disable or delete the leaked AccessKey in the RAM Console immediately, create a new one, and push the updated `apps/server/.env.production` to Workers Secrets again. # Email OTP Login (http://page.easystarter.dev/docs/mobile/integrations/authentication/email-otp) ## Email OTP Login EasyStarter includes a built-in email OTP (one-time password) login powered by the [Better Auth Email OTP plugin](https://www.better-auth.com/docs/plugins/email-otp). Users simply enter their email address and receive a one-time verification code to sign in — no password required. ### How It Works 1. The user enters their email address on the sign-in page 2. The server sends a one-time verification code to that email via the [Email Service](/docs/web/integrations/email) 3. The user enters the received code 4. The server verifies the code and completes sign-in (auto-registers if the user doesn't exist) ### Enabling Email OTP Login Email OTP login is controlled by a feature flag in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" auth: { methods: { emailOtpEnabled: true, }, } ``` ### Prerequisites Email OTP login depends on the email sending capability. Make sure you have completed the [Email Service](/docs/web/integrations/email) setup first. ### OTP Configuration The verification code behavior is configured in the `auth.otp.email` section of `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" auth: { otp: { email: { // Number of digits in the verification code otpLength: 6, // Code expiration time in seconds expiresInSeconds: 300, // Maximum verification attempts per issued code allowedAttempts: 3, // Client-side resend cooldown in seconds resendCooldownSeconds: 60, }, }, } ``` ### Rate Limiting The server applies dedicated rate limits to email OTP endpoints to prevent abuse: ```ts title="apps/server/src/lib/auth.ts" rateLimit: { customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 }, "/sign-in/email-otp": { window: 60, max: 10 }, }, } ``` - Send verification code: max 3 requests per 60 seconds - Verify and sign in: max 10 requests per 60 seconds # Email Password Login (http://page.easystarter.dev/docs/mobile/integrations/authentication) ## Email Password Login EasyStarter's mobile app uses [Better Auth](https://better-auth.com/) for authentication, with built-in email and password sign-in. Server configuration lives in `apps/server/src/lib/auth.ts`. ## Required Environment Variables ```bash BETTER_AUTH_SECRET= ``` ### Get `BETTER_AUTH_SECRET` `BETTER_AUTH_SECRET` is used by Better Auth to sign and encrypt session data. It must be a sufficiently long random string. ```bash openssl rand -base64 32 ``` Copy the output and add it to both local and production environments: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ### Set Environment Variables For local development, add everything to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` For production, put sensitive values in `apps/server/.env.production`: ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ## Email Password Authentication Features The mobile `authClient` is configured in `apps/native/lib/auth/auth.client.ts`, using the `@better-auth/expo` adapter to handle deep link callbacks and cookie storage. Currently supported: - Email/password signup and login - Cookie-based cross-platform session management # Social Login (http://page.easystarter.dev/docs/mobile/integrations/authentication/social-login) ## Social Login EasyStarter's mobile app includes the following social login providers: - Google OAuth - Apple native sign-in (iOS only) Server configuration lives in `apps/server/src/lib/auth.ts`. Apple Sign-In uses the **native ID token flow**: the app triggers the system-level Apple sign-in sheet, retrieves the `identityToken`, and passes it directly to Better Auth for verification — no web redirect needed. ## Required Environment Variables ```bash GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= APPLE_APP_BUNDLE_IDENTIFIER= ``` ### Create Google OAuth Client Google Cloud Console: [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 1. Log in and select or create a project 2. Go to `APIs & Services > Credentials` 3. Click `Create Credentials` → select `OAuth client ID` 4. Complete the `OAuth consent screen` if prompted 5. Select `Web application` as the application type 6. Configure the redirect URI Key fields: - `Authorized JavaScript origins`: your frontend URL, e.g. `https://yourdomain.com` - `Authorized redirect URIs`: `{SERVER_URL}/api/auth/callback/google` Mobile Google OAuth requires an HTTPS callback URL. Use [ngrok](https://ngrok.com/) to expose your local server over HTTPS. **Why ngrok is required for mobile development** Mobile Google Sign-In uses a **deep link callback flow**, not a simple browser redirect: 1. The app opens Google's login page via `expo-web-browser` 2. After the user logs in, Google redirects to the server callback URL (`SERVER_URL/api/auth/callback/google`) 3. The server processes the callback, then redirects the user back to the app via a deep link (e.g. `myapp://callback`) 4. The OS intercepts the deep link, relaunches the app, and the login completes This flow has two hard requirements: - **Google OAuth enforces HTTPS** — `http://localhost` is not accepted - Physical devices and simulators cannot access your dev machine's `localhost` directly — the server must have a publicly reachable address **Ensure Deep Link Scheme Consistency** In step 3 above, the server redirects the user back to the app via a deep link (e.g. `myapp://callback`). The scheme must match in two places: - `nativeScheme` in `packages/app-config/src/app-config.ts` ```ts title="packages/app-config/src/app-config.ts" nativeScheme: "myapp" ``` - `scheme` in `apps/native/app.json` ```json title="apps/native/app.json" { "expo": { "scheme": "myapp" } } ``` These two values must be identical, otherwise the OS cannot relaunch the app after the Google OAuth callback. ngrok solves both: it exposes your local `localhost:3001` as a public HTTPS URL, so Google can complete the redirect and the server can send the user back to the app via deep link. **Install ngrok** Download and install from the [ngrok website](https://ngrok.com/download), or use Homebrew: ```bash brew install ngrok ``` After installing, sign up for a free account and authenticate: ```bash ngrok config add-authtoken YOUR_AUTH_TOKEN ``` Find your Auth Token at [ngrok Dashboard → Your Authtoken](https://dashboard.ngrok.com/get-started/your-authtoken). **Start the tunnel** After starting the local server (`pnpm dev:server`), open a new terminal and run: ```bash ngrok http 3001 ``` ngrok will output an HTTPS URL like: ``` Forwarding https://xxxx-xxxx.ngrok-free.app -> http://localhost:3001 ``` Use this URL as the Google OAuth redirect URI: ``` https://xxxx-xxxx.ngrok-free.app/api/auth/callback/google ``` Also update `SERVER_URL` in `.dev.vars` to this ngrok URL so Better Auth's `baseURL` and the OAuth callback stay in sync. > **Note**: The free ngrok plan generates a new URL on every restart. You'll need to update both the Google Cloud Console redirect URI and your local `.dev.vars` each time. After creating, you'll receive: - `Client ID` → `GOOGLE_CLIENT_ID` - `Client Secret` → `GOOGLE_CLIENT_SECRET` ### Configure Apple Native Sign-In The mobile Apple Sign-In uses the **native iOS flow**: the app calls `expo-apple-authentication` to trigger the system sign-in sheet, retrieves the Identity Token, and the server validates it using the Bundle ID (`APPLE_APP_BUNDLE_IDENTIFIER`) as the audience. Reference: [Better Auth Apple docs](https://better-auth.com/docs/authentication/apple) **Enable Sign In with Apple on your App ID** The App ID was already created in the [Configure app.json](/docs/mobile/integrations/app-json) chapter. Now enable Apple Sign-In on it: 1. Log in to [Apple Developer Portal → Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Find and click your App ID 3. Under Capabilities, check `Sign In with Apple` 4. Click Continue → Save **Create and configure a Service ID** 1. In `Identifiers`, click `+`, select `Service IDs` → Continue 2. Enter a Description and Identifier (e.g. `com.yourcompany.yourapp.si`) — this becomes your `APPLE_CLIENT_ID` 3. Click Register 4. Click the Service ID you just created → check `Sign In with Apple` → click `Configure` 5. Under **Primary App ID**, select your App ID 6. Configure **Domains and Subdomains** and **Return URLs**: | Environment | Domains and Subdomains | Return URLs | |-------------|------------------------|-------------| | Development | `xxxx-xxxx.ngrok-free.app` | `https://xxxx-xxxx.ngrok-free.app/api/auth/callback/apple` | | Production | `server.yourdomain.com` | `https://server.yourdomain.com/api/auth/callback/apple` | > In development, use your ngrok URL. Remember to update both the Domain and Return URL here every time ngrok restarts. In production, use your actual server domain. 7. Click Next → Done → Continue → Save ### Set Environment Variables For local development, add everything to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret APPLE_APP_BUNDLE_IDENTIFIER=com.yourcompany.yourapp ``` For production, put sensitive values in `apps/server/.env.production`: ```bash title="apps/server/.env.production" GOOGLE_CLIENT_SECRET=your-google-client-secret ``` Add non-sensitive IDs to `vars` in `apps/server/wrangler.jsonc`: ```json title="apps/server/wrangler.jsonc" "vars": { "GOOGLE_CLIENT_ID": "your-google-client-id", "APPLE_APP_BUNDLE_IDENTIFIER": "com.yourcompany.yourapp" } ``` ## Supported Social Login Providers - Google OAuth (via deep link callback) - Apple native sign-in (via ID token, iOS only) Apple Sign-In is only available on physical devices and TestFlight. It does **not** work in the iOS Simulator. # Cloudflare (http://page.easystarter.dev/docs/mobile/integrations/cloudflare) ## Cloudflare Integration EasyStarter runs its server layer on Cloudflare infrastructure, mainly using: - Cloudflare Workers - Cloudflare D1 - Cloudflare R2 If you want to run database migrations, deploy the server, or configure object storage, you will usually need the Cloudflare credentials below first. ## Required Environment Variables ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` - `CLOUDFLARE_ACCOUNT_ID`: your Cloudflare account ID - `CLOUDFLARE_API_TOKEN`: the API token used to call the Cloudflare API These values are typically used by `apps/server/drizzle.config.ts` so `drizzle-kit` can run database commands over the D1 HTTP driver. ## Get `CLOUDFLARE_ACCOUNT_ID` Official doc: [Find account and zone IDs](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/) ### Option 1: From Account Home 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Open `Account Home`. 3. Find your account row. 4. Click the menu button on the right. 5. Select `Copy account ID`. That copied value is your `CLOUDFLARE_ACCOUNT_ID`. ### Option 2: From Workers & Pages 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Open `Workers & Pages`. 3. Find `Account ID` in the `Account details` section. 4. Copy the value. ## Get `CLOUDFLARE_API_TOKEN` Official doc: [Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) Use an API Token here, not the legacy Global API Key. ### Create the token 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Go to `My Profile > API Tokens`. 3. Click `Create Token`. 4. Choose `Custom token`. 5. Give it a clear name, such as `easystarter-d1-migrate`. 6. Add these permissions: - `Account` -> `D1` -> `Edit` - `Account` -> `Workers R2 Storage` -> `Edit` - `Account` -> `Workers Scripts` -> `Edit` 7. Scope the resources to the account used by this project. 8. Click `Continue to summary`. 9. Review the permissions and resource scope. 10. Click `Create Token`. 11. Copy the generated token secret. That copied value is your `CLOUDFLARE_API_TOKEN`. ### Notes - the token secret is shown only once - if you lose it, create a new token - store it only in `.dev.vars`, `.env.production`, or CI secrets ## Where To Put Them Place these variables in the `apps/server` directory as either `.dev.vars` or `.env.production`. ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` ```bash title="apps/server/.env.production" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` # Credits (http://page.easystarter.dev/docs/mobile/integrations/credits) ## Credits EasyStarter ships a credit system backed by a server-side ledger. In the app, users buy credit packages through RevenueCat in-app purchases: - **Sell credit packages** through RevenueCat (iOS / Android) - **Grant free credits on signup**, with optional expiration - **Abuse protection**: signup grants require a verified email and are rate-limited per email, IP, and user agent - **Consume credits per feature** with idempotent, race-safe accounting - **Ready-made UI**: balance, purchase screen, transaction history The ledger is the single source of truth. The client never grants credits locally — only the RevenueCat webhook (on purchase) and server-side consumption write to it. > Building the web app too? Credits share one server and config — see [Web · Credits](/docs/web/integrations/credits) for the web setup. ## Architecture | Layer | Location | | --- | --- | | Config | `packages/app-config/src/app-config.ts` | | Server service | `apps/server/src/credits/*` | | API routes | `apps/server/src/routers/common/credits.ts` | | App UI | `apps/native/app/(tabs)/(profile)/credits*.tsx`, `apps/native/hooks/use-credits.ts` | | Purchases | `apps/native/lib/payments/revenuecat.ts` | The credit tables: | Table | Purpose | | --- | --- | | `credit_account` | Current balance and aggregate counters per user | | `credit_transaction` | Immutable ledger rows; the `(sourceProvider, sourceType, sourceId)` tuple is the idempotency key | | `credit_order` | App purchase order lifecycle | | `credit_signup_grant_claim` | Signup grant eligibility and abuse checks | ## 1. Configure credit packages Everything is configured in `packages/app-config/src/app-config.ts`. ### Enable native credits ```ts title="packages/app-config/src/app-config.ts" native: { credits: { enabled: true, signupGrant: creditSignupGrant, packages: nativeCreditPackages, }, }, ``` ### Configure the signup grant Grants free credits to a new user on first balance read. Set `expiresInDays: null` (or omit) for credits that never expire. ```ts title="packages/app-config/src/app-config.ts" const creditSignupGrant = { enabled: true, amount: 100, // credits granted on signup expiresInDays: 30, // null = never expires } satisfies NonNullable; ``` ### Create the consumable product Credit packages are **consumable** in-app products — they can be bought repeatedly and are "used up" by the ledger. This differs from the lifetime unlock, which is Non-Consumable. **iOS — App Store Connect** 1. **Monetization → In-App Purchases → +**. 2. Select **Consumable**. 3. Set a **Reference Name** (e.g. `100 Credits`) and **Product ID** (e.g. `com.yourapp.credits.starter`). 4. Add a price and localization, then **Save**. **Android — Google Play Console** 1. **Monetize → In-app products → Create product**. 2. Set the **Product ID** (e.g. `credits_starter`), name, description, and default price. 3. **Save**, then **Activate** (RevenueCat can't see inactive products). **RevenueCat** 1. **Product catalog → Products → Import**, select the consumable products, and **Import**. 2. That's all credits need — **no Offering or Entitlement**. The app fetches the product directly by ID (`Purchases.getProducts(..., NON_SUBSCRIPTION)`), and the server grants credits on the `NON_RENEWING_PURCHASE` webhook by matching the product id. Offerings and Entitlements are only for subscriptions and lifetime access. > Full RevenueCat setup (app config, store credentials, webhook) lives in the [RevenueCat](/docs/mobile/integrations/iap/revenuecat) and [Store Products](/docs/mobile/integrations/iap/store-products) guides. ### Configure app packages Put the product ids from the previous step into `native` for each platform. `providerProductId` must exactly match the store product id. ```ts title="packages/app-config/src/app-config.ts" const nativeCreditPackages = [ { id: "starter", // internal package id amount: 100, // credits delivered after purchase native: { ios: { provider: "revenuecat", providerProductId: "easystarter_credits_starter_ios", currency: "usd", amountCents: 499, status: "active", }, android: { provider: "revenuecat", providerProductId: "easystarter_credits_starter_android", currency: "usd", amountCents: 499, status: "active", }, }, }, ] satisfies AppCreditsConfig["packages"]; ``` ### Add package labels Add a title and description for each package `id` so the purchase screen can render it. ```jsonc title="packages/i18n/src/messages/native/en.json" "credits": { "packages": { "starter": { "title": "Starter pack", "description": "{count} credits for light usage." } } } ``` **Configuration rules** - `amount` and `amountCents` must be positive integers. - `providerProductId` is required, must be unique, and must match the RevenueCat product id. - Configure credit products as **non-subscription (consumable)** products in RevenueCat. - Set `status: "archived"` to hide a package without deleting history. - If the same package is also sold on web, reuse the same `id` (with matching `amount` and `status`) — see [Web · Credits](/docs/web/integrations/credits). ## 2. Set up the server ### Run migrations ```bash pnpm db:migrate:local # local D1 pnpm db:migrate # remote D1 ``` ### Configure the RevenueCat webhook secret Credits reuse your RevenueCat integration, so no extra secret is needed beyond what [In-App Purchases](/docs/mobile/integrations/iap/revenuecat) already requires: | Provider | Secret | | --- | --- | | RevenueCat | `REVENUECAT_WEBHOOK_SECRET` | ### Confirm the maintenance cron `apps/server/src/index.ts` runs `runCreditMaintenance` on a daily schedule defined in `apps/server/wrangler.jsonc`. It expires free credits past their window and cancels stale pending orders. ```jsonc title="apps/server/wrangler.jsonc" "triggers": { "crons": ["10 16 * * *"] } ``` ## 3. Consume credits Spending credits is the part you wire into your own features. Prefer the **server-side** service from inside a route, so balance checks can't be bypassed by the client. ```ts title="server route" await context.credits.consumeCredits({ user: { userId: context.session.user.id }, amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` From the app, use the `useCredits` hook: ```ts const credits = useCredits(); await credits.consume({ amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` > `idempotencyKey` must identify one real usage event (8–120 chars). Retrying with the same key returns the current balance without charging twice. Consumption spends soonest-to-expire credits first; an insufficient balance throws `Insufficient credits`. ## Ledger rules - Signup credits are granted lazily on first balance / transaction read or consume. They require a verified email and are rate-limited per email, IP, and user agent. - Purchased credits never expire (`expiresAt = null`). - Free grants expire after `expiresInDays`; the daily cron sweeps them. - Consumption spends soonest-to-expire credits first, then permanent paid credits. - Refunds revoke only the unspent remainder of the original purchase. # Database (http://page.easystarter.dev/docs/mobile/integrations/database) ## Database The project uses [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) as its database layer. ### Create the D1 database See official docs: [D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) Option 1: Cloudflare Dashboard 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **Storage & databases → D1 SQL database** 3. Click **Create database** 4. Enter a database name, for example `easysaas-db` 5. Choose a location if needed 6. Click **Create** Once created, copy the `database_id` from the database details page. Option 2: Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` On success, Wrangler outputs a D1 binding snippet that contains the `database_id`. ### Configure the D1 database ID After obtaining your `database_id`, add it to the following two locations. Environment variables: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` For how to obtain `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, see [Cloudflare Integration](/docs/web/integrations/cloudflare). Set `database_id` in: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` and: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler config: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` That means `apps/server/wrangler.jsonc` must use the same `database_id`. ### Run the local database workflow For local database development, use these three commands in this order: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` Generate migration files from `apps/server/src/db/schema`. `pnpm db:migrate:local` Apply the generated migrations to the local D1 database. This command already handles local D1 initialization. `pnpm db:studio:local` Open the local D1 visual UI so you can inspect tables and data. # Cloudflare Email Service (http://page.easystarter.dev/docs/mobile/integrations/email) ## Cloudflare Email Service Cloudflare Email Service is a convenient option when your domain is already managed by Cloudflare. Once configured, users can receive sign-up verification, password reset, and email verification code messages in their own inboxes. Cloudflare Email Service does not require a separate email API key or any additional email environment variables. ## Enable email sending in production ### Enable Email Sending 1. Sign in to the [Cloudflare dashboard](https://dash.cloudflare.com/) 2. Go to **Compute → Email Service → Email Sending** 3. Click **Onboard Domain** and select your sender domain 4. Complete the DNS setup shown on the page 5. Wait until the domain is enabled When the domain is already managed by Cloudflare, the required records can usually be configured directly from the dashboard. Sending to arbitrary real user addresses requires the [Workers Paid plan](https://developers.cloudflare.com/email-service/platform/pricing/). ### Select Cloudflare as the email provider In `packages/app-config/src/app-config.ts`, switch the email provider to `cloudflare` and enter the domain you just verified: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "cloudflare", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### Deploy and receive a test email Deploy the server normally. A deployed Worker connects directly to the real Cloudflare email service. `remote: true` is only for local testing and is not required in production. After deployment, use a real address to trigger sign-up verification, forgot password, or an email verification code. Receiving the message confirms that production sending is active. If it does not appear immediately, check the spam folder and the activity log on the Cloudflare Email Sending page. ## Receive real email during local development By default, Cloudflare simulates email sending during local development. The message is shown in the terminal and saved as a local preview, but nothing is delivered to a real inbox. To receive the message in your own test inbox, temporarily enable remote sending in `apps/server/wrangler.jsonc`: ```jsonc title="apps/server/wrangler.jsonc" "send_email": [ { "name": "EMAIL", "remote": true, }, ], ``` Make sure Wrangler is signed in to the Cloudflare account that owns the onboarded domain, restart the local server, and trigger sign-up verification, forgot password, or an email verification code. The message will be delivered to the test address you entered. Check the spam folder if it is not visible in the inbox. Remove `remote: true` after testing to avoid sending real email accidentally during everyday development. Cloudflare activity logs may appear later than the email itself, so use the received test message as the primary confirmation. # Resend Email Service (http://page.easystarter.dev/docs/mobile/integrations/email/resend) ## Resend Email Service Resend sends email using an API key. Once configured, users can receive sign-up verification, password reset, and email verification code messages in their own inboxes. ## Enable email sending in production ### Create a Resend API Key 1. Create an account at [resend.com](https://resend.com/) 2. Open the [API Keys](https://resend.com/api-keys) page 3. Click **Create API Key** 4. Select **Sending access** 5. Copy the API Key immediately after creating it The key starts with `re_` and is only displayed once, so store it safely. ### Verify your sender domain 1. Open **Domains** in Resend 2. Click **Add Domain** and enter your sender domain 3. Add the DNS records shown by Resend to your DNS provider 4. Return to Resend and click **Verify DNS Records** 5. Wait until the domain is verified Once verified, you can send from an address such as `noreply@yourdomain.com`. ### Add the production configuration Add `RESEND_API_KEY` to `apps/server/.env.production`: ```bash title="apps/server/.env.production" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Then select Resend in `packages/app-config/src/app-config.ts` and enter the verified domain: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "resend", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### Deploy and receive a test email Push the production secrets and deploy the server normally. After deployment, use a real address to trigger sign-up verification, forgot password, or an email verification code. Receiving the message confirms that production sending is active. If it does not appear immediately, check the spam folder and Resend Logs. ## Receive real email during local development Add the same `RESEND_API_KEY` to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Make sure the email provider is set to `resend`, restart the local server, and trigger sign-up verification, forgot password, or an email verification code. The message will be delivered directly to the test address you entered. During initial testing, Resend allows you to send to the email address registered with your Resend account. Verify your sender domain before sending to other users. If the message does not arrive, check the spam folder and Resend Logs. # RevenueCat (http://page.easystarter.dev/docs/mobile/integrations/iap/revenuecat) ## RevenueCat In-App Purchases EasyStarter's mobile app uses [RevenueCat](https://www.revenuecat.com/) to manage iOS and Android subscriptions and in-app purchases. RevenueCat handles the App Store / Google Play integration and syncs subscription status to the server via Webhook. ## Prerequisites Make sure the following accounts and setup are ready before starting: - [RevenueCat account](https://app.revenuecat.com/signup) (free to start) - **iOS**: Apple Developer account, with in-app purchase products created in App Store Connect - **Android**: Google Play Console account, with subscription products created ## Prerequisites: Create Store Products Before configuring RevenueCat, create subscriptions and in-app purchase products in App Store Connect and Google Play Console, and note each product's **Product ID**. See the [Create Store Products](/docs/mobile/integrations/iap/store-products) guide for detailed steps. ## Configure RevenueCat Sign in to [RevenueCat](https://app.revenuecat.com) and follow these steps to complete the backend configuration. ### 1. Create an App Go to **Project Settings → Apps** and click **New app configuration**, then select your platform (App Store for iOS, Google Play for Android). - **iOS**: Enter the App name and Bundle ID, then complete the following credential setup following the official docs: - [In-app purchase key configuration](https://www.revenuecat.com/docs/service-credentials/itunesconnect-app-specific-shared-secret/in-app-purchase-key-configuration) - [App Store Connect API key configuration](https://www.revenuecat.com/docs/service-credentials/itunesconnect-app-specific-shared-secret/app-store-connect-api-key-configuration) - **Android**: Enter the App name and Package Name, then configure [Google Play service credentials](https://www.revenuecat.com/docs/service-credentials/creating-play-service-credentials). Click **Save changes** when done. ### 2. Import Products Products are RevenueCat's mapping to the specific items you created in App Store Connect or Google Play Console. Go to **Product catalog → Products** and click **Import** in the top-right corner. RevenueCat will automatically fetch the product list from your connected store. Select all the products you need (monthly subscription, annual subscription, lifetime purchase, etc.) and click **Import**. If the import returns an empty list, the store credentials may not have propagated yet, or the products are not in a submittable state. iOS products must be in "Ready to Submit" status or higher before RevenueCat can fetch them. Once imported, each Product will show its **Product Identifier** — the same product ID from App Store Connect or Google Play. Take note of these; you will need them in `app-config.ts`. ### 3. Configure Offerings Offerings define the purchase options presented to users. Each Offering contains one or more Packages, where each Package maps to a specific Product. 1. Go to **Product catalog → Offerings**. RevenueCat creates a **default** Offering automatically. 2. Click **default** to open its detail page, then click **Add Package** to add the following three packages: - **Monthly** — Package Type: `Monthly`, link to your monthly subscription Product - **Yearly** — Package Type: `Annual`, link to your annual subscription Product - **Lifetime** — Package Type: `Lifetime`, link to your lifetime purchase Product 3. For each Package, select the corresponding Product for iOS and Android in the right panel, then click **Save**. Offerings control which purchase options appear in the app. You can create multiple Offerings for A/B testing, but the SDK defaults to the `default` Offering. ### 4. Configure Entitlements Entitlements define what access a user receives after a purchase — typically one Entitlement per app, for example `pro`. 1. Go to **Product catalog → Entitlements** and click **New** in the top-right corner. Enter an **Identifier** (e.g. `pro`) and click **Save**. 2. Open the Entitlement's detail page and click **Attach**. In the product list, select all Products that should grant this entitlement (Monthly, Yearly, and Lifetime) and click **Attach**. The Entitlement Identifier must match the `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` value in `eas.json`. If you use `pro`, set the environment variable to `pro`. Once configured, whenever a user purchases any attached Product, RevenueCat automatically marks this Entitlement as active. The app uses the SDK to check whether the user has an active Entitlement and gates premium features accordingly. ### 5. Configure a Paywall (optional) RevenueCat includes a visual paywall editor that lets you update your in-app purchase screen without releasing a new app version. Go to **Paywalls** and click **New paywall**. Choose a template to start editing: - Select the Offering to bind (choose `default`) - Enter a paywall name - Set the URLs for your Privacy Policy and Terms of Service Per App Store Review Guidelines, the in-app purchase screen **must** include links to your Privacy Policy and Terms of Service, or your app risks rejection. If the chosen template has no Lifetime Package slot, duplicate an existing Package block, change its type to `Lifetime`, and update the display copy. When finished, click **Publish** to make the paywall live in your app. For more customization options and variables, see the [RevenueCat paywall variables docs](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/variables). ### 6. Get API Key and Entitlement ID **SDK API Keys**: Go to **Project Settings → API Keys**. Find the Public SDK key for each platform (iOS keys start with `appl_`, Android with `goog_`), click **Show Key → Copy**, and paste them into `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` and `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` in `eas.json`. **Entitlement ID**: Go to **Product catalog → Entitlements**, copy the **Identifier** of your Entitlement (e.g. `pro`), and paste it into `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` in `eas.json`. ### 7. Configure Webhook (sync subscription status to the server) RevenueCat pushes subscription events (purchase, renewal, cancellation, etc.) to the server in real time via Webhook. EasyStarter's server already includes the handler — you just need to configure the Webhook URL. #### Development (ngrok) In local development, the server runs on `localhost` which RevenueCat cannot reach directly. Use [ngrok](https://ngrok.com) to expose your local port to the internet. 1. Install ngrok (if not already installed): ```bash brew install ngrok ``` 2. Start the local server: ```bash pnpm dev:server ``` The server listens on `http://localhost:3001` by default. 3. Open a ngrok tunnel: ```bash ngrok http 3001 ``` ngrok will output a public URL, for example: ``` Forwarding https://a1b2-123-456-789.ngrok-free.app -> http://localhost:3001 ``` 4. In the RevenueCat Dashboard → **Project Settings → Integrations → Webhooks** → **Add webhook**, set the Webhook URL to: ``` https://a1b2-123-456-789.ngrok-free.app/api/webhooks/revenuecat ``` 5. In the **Authorization header** field, enter a random secret you generate yourself. Use the full Bearer Token format: ```bash # Generate a random value openssl rand -hex 32 ``` Prefix the output with `Bearer `, e.g. `Bearer a1b2c3d4...`, then paste that complete value into the RevenueCat **Authorization header** field. Add the **same full value** to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" REVENUECAT_WEBHOOK_SECRET=Bearer your_random_secret ``` `REVENUECAT_WEBHOOK_SECRET` is a value you generate and configure yourself — it is **not** a secret issued by RevenueCat. RevenueCat will forward this exact value as the `Authorization` request header on every webhook call; your server then validates it. Using the full `Bearer xxx` form is recommended for clarity and to avoid misconfiguration. The free ngrok tier generates a new URL on every restart — remember to update the Webhook URL in RevenueCat when that happens. Sign up for a ngrok account to get a persistent domain if you debug webhooks frequently. #### Production Once deployed to Cloudflare Workers, the server has a fixed public URL and can be configured directly. 1. In the RevenueCat Dashboard → **Project Settings → Integrations → Webhooks** → **Add webhook**, set the Webhook URL to: ``` https://your-server.workers.dev/api/webhooks/revenuecat ``` 2. In the **Authorization header** field, enter a random secret you generate yourself (same format as development): ```bash openssl rand -hex 32 ``` Prefix the output with `Bearer `, e.g. `Bearer a1b2c3d4...`, paste it into the RevenueCat **Authorization header** field, and add the **same full value** to `apps/server/.env.production` (or manage it via `wrangler secret`): ```bash title="apps/server/.env.production" REVENUECAT_WEBHOOK_SECRET=Bearer your_random_secret ``` 3. Push secrets to Cloudflare: ```bash pnpm -F server secrets:bulk:production ``` ## Update `eas.json` environment variables Fill in the API Keys and Entitlement ID in the `env` block of each build profile in `apps/native/eas.json`: ```jsonc title="apps/native/eas.json" { "build": { "development": { "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } }, "production": { "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } } } } ``` > `EXPO_PUBLIC_` variables are injected into client-side code by Expo. RevenueCat SDK keys are public client credentials — it is safe to include them here. ## Update `app-config.ts` pricing config `packages/app-config/src/app-config.ts` defines the pricing plans shown in the app under `native.payments`. The `providerPriceId` values must exactly match the product IDs in App Store Connect / Google Play Console: ```ts title="packages/app-config/src/app-config.ts" native: { payments: { enabled: true, provider: "revenuecat", ios: { plans: [ { id: "pro", prices: [ { id: "monthly", provider: "revenuecat", providerPriceId: "easystarternative_10_1m", // App Store Connect product ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, { id: "yearly", provider: "revenuecat", providerPriceId: "easystarternative_100_1y", currency: "usd", amountCents: 10000, priceType: "subscription", interval: "year", status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "revenuecat", providerPriceId: "easystarternative_299_lifetime", currency: "usd", amountCents: 29900, priceType: "lifetime", status: "active", }, ], }, ], }, android: { plans: [ { id: "pro", prices: [ { id: "monthly", provider: "revenuecat", providerPriceId: "pro_monthly_android", // Google Play Console product ID currency: "usd", amountCents: 800, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, }, ``` | Field | Description | | --- | --- | | `providerPriceId` | Must exactly match the product ID in App Store Connect / Google Play Console | | `amountCents` | Price shown in the app UI, in cents ($10.00 = 1000) | | `priceType` | `subscription` or `lifetime` (one-time purchase) | | `status` | `active` to show the product, `inactive` to hide it | ## Server-side variables The server only needs the `REVENUECAT_WEBHOOK_SECRET` configured in the Webhook steps above. It is used to verify requests sent by RevenueCat to `/api/webhooks/revenuecat`. No additional RevenueCat Secret API Key is required. # Create Store Products (http://page.easystarter.dev/docs/mobile/integrations/iap/store-products) ## Creating In-App Purchase Products Before configuring RevenueCat, you need to create products in App Store Connect and Google Play Console and note each product's **Product ID**. These IDs will be used in both RevenueCat and `app-config.ts`. --- ## iOS: App Store Connect ### Prerequisites - Your app must already exist in App Store Connect - Bundle ID matches `ios.bundleIdentifier` in `app.json` ### Create a Subscription Group (Monthly / Annual) Auto-renewable subscriptions must belong to a **Subscription Group**. Subscriptions within the same group are mutually exclusive — users can only be subscribed to one at a time. ### Create a Subscription Group 1. Go to [App Store Connect](https://appstoreconnect.apple.com) → select your app 2. In the left menu, click **Monetization → Subscriptions** 3. Click **Create** to add a new subscription group, enter a group name (e.g. `Pro`) 4. Click **Create** to confirm ### Add a Monthly Subscription 1. Under the subscription group, click **+** to add a subscription 2. Fill in: - **Reference Name**: Internal name (e.g. `Pro Monthly`), not shown to users - **Product ID**: Unique identifier (e.g. `com.yourcompany.yourapp.pro.monthly`) > **Naming convention**: Use `bundleId.plan.interval` format, lowercase only, letters/numbers/dots, cannot be changed after creation. 3. Click **Create** ### Configure Monthly Subscription Details 1. Set duration to **1 Month** 2. Click **Add Subscription Price** to select a price tier (e.g. Tier 10 ≈ $0.99) 3. Add **Localization**: - **Subscription Name**: Shown to users (e.g. `Pro Monthly`) - **Description**: Brief description (e.g. `Billed monthly, cancel anytime`) 4. Upload a screenshot under **Subscription Review Information** (required for first submission) 5. Click **Save** ### Add an Annual Subscription Repeat the steps above to add another subscription in the same group: - **Product ID**: e.g. `com.yourcompany.yourapp.pro.yearly` - **Duration**: 1 Year - **Price**: Typically monthly × 10 (roughly 17% off) ### Create a One-Time Purchase (Lifetime) One-time purchases use a **Non-Consumable** in-app purchase — once bought, it's unlocked permanently. ### Go to In-App Purchases 1. In [App Store Connect](https://appstoreconnect.apple.com) → your app 2. Click **Monetization → In-App Purchases** in the left menu 3. Click **+** to create a new product ### Select Type and Fill In Details 1. Select **Non-Consumable** (permanent, non-expiring) 2. Fill in: - **Reference Name**: e.g. `Pro Lifetime` - **Product ID**: e.g. `com.yourcompany.yourapp.pro.lifetime` 3. Click **Create** ### Set Price and Localization 1. Click **Add Pricing** to select a price tier (e.g. Tier 30 ≈ $29.99) 2. Add Localization with display name and description 3. Click **Save** > **Review note**: Newly created in-app purchase products start as **Missing Metadata** or **Waiting for Review**. First-time products must be submitted for review alongside an app version. --- ## Android: Google Play Console ### Prerequisites - Your app must already exist in Google Play Console - Billing profile configured (country / banking info) - At least one APK / AAB has been uploaded (in-app products require an app version) ### Create Subscriptions (Monthly / Annual) Google Play subscriptions follow a three-tier structure: **Subscription → Base Plan → Offer** ### Create a New Subscription 1. Go to [Google Play Console](https://play.google.com/console) → select your app 2. Click **Monetize → Subscriptions** in the left menu 3. Click **Create subscription** 4. Fill in: - **Product ID**: Unique identifier (e.g. `pro_monthly`) > **Naming convention**: Lowercase only, letters/numbers/underscores, cannot be changed after creation. - **Name**: Shown to users (e.g. `Pro Monthly`) - **Description**: Brief description 5. Click **Save** ### Add a Base Plan 1. Under the subscription you just created, click **Add base plan** 2. Configure: - **Base plan ID**: e.g. `monthly-base` - **Billing period**: **Monthly** or **Yearly** - **Price**: e.g. $9.99 - **Free trial**: Optional (e.g. 7-day free trial) 3. Click **Save & publish base plan** > A single subscription product can have multiple base plans. You can put monthly and yearly as base plans under one subscription ID, or create two separate subscriptions. EasyStarter recommends **separate subscriptions per billing period** for clearer RevenueCat mapping. ### Activate the Subscription 1. After saving, the base plan status is **Inactive** 2. Click **Activate** (RevenueCat cannot detect products until activated) 3. Repeat the same steps for the annual subscription (Product ID e.g. `pro_yearly`) ### Create a One-Time Purchase (Lifetime) One-time purchases use **In-app products**: ### Create a New In-App Product 1. Click **Monetize → In-app products** in the left menu 2. Click **Create product** 3. Fill in: - **Product ID**: e.g. `pro_lifetime` - **Name**: e.g. `Pro Lifetime` - **Description**: Brief description - **Default price**: e.g. $29.99 4. Click **Save** ### Activate the Product 1. Product is saved as **Inactive** 2. Click **Activate** --- ## Product ID Reference After creating all products, record their IDs — you'll need them in RevenueCat and `app-config.ts`: | Product | iOS Product ID | Android Product ID | | --- | --- | --- | | Monthly | `com.yourcompany.yourapp.pro.monthly` | `pro_monthly` | | Annual | `com.yourcompany.yourapp.pro.yearly` | `pro_yearly` | | Lifetime | `com.yourcompany.yourapp.pro.lifetime` | `pro_lifetime` | > Product IDs can differ between platforms. Just fill them in separately under the `ios` and `android` blocks in `app-config.ts`. # App Notifications (http://page.easystarter.dev/docs/mobile/integrations/notifications) ## App Notifications EasyStarter disables App notifications by default because iOS and Android push credentials must be prepared before the app can be signed and built successfully. Complete these steps to enable notifications: 1. Enable notifications in EasyStarter 2. Regenerate the native projects 3. Configure iOS APNs 4. Configure Android FCM V1 5. Configure the Expo Access Token 6. Rebuild and test the app ## 1. Enable notifications Open `packages/app-config/src/app-config.ts` and set `notifications.enabled` to `true`: ```ts title="packages/app-config/src/app-config.ts" notifications: { enabled: true, provider: "expo", }, ``` From the repository root, run: ```bash pnpm -F native prebuild ``` This command updates the existing iOS and Android projects and adds the native notification settings required by the new configuration. ### Is `--clean` required? No. Start with the command above without `--clean`. Use `pnpm -F native prebuild --clean` only when the regular prebuild fails, notification configuration is still missing after a rebuild, old plugin configuration remains, or you intentionally want to regenerate both native projects. Before using `--clean`, check for uncommitted Native changes: ```bash git status --short apps/native/ios apps/native/android ``` If there is output, create a Git backup: ```bash git add apps/native/ios apps/native/android git commit -m "chore(native): back up native projects before clean prebuild" ``` Alternatively, copy both directories outside the project: ```bash native_backup_path="../easystarter-native-backup-$(date +%Y%m%d-%H%M%S)" mkdir "$native_backup_path" cp -R apps/native/ios apps/native/android "$native_backup_path/" ``` After confirming the backup, run the clean command. Compare the old and new projects and restore only necessary manual changes instead of replacing the regenerated projects with the entire backup. You must rebuild and reinstall the app after this command. Restarting the development server or publishing an OTA update is not enough. ## 2. Check your app identity In `apps/native/app.config.ts`, make sure these values belong to your project: - `extra.eas.projectId` - `ios.bundleIdentifier` - `ios.appleTeamId` - `android.package` If you have not created an Expo project yet, run: ```bash cd apps/native pnpm dlx eas-cli init ``` ## 3. Configure iOS APNs Use Expo's [Push Notifications Setup](https://docs.expo.dev/push-notifications/push-notifications-setup/) and [iOS Credentials](https://docs.expo.dev/app-signing/app-credentials/) guides as references. 1. Open [Apple Developer Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Select the App ID matching `ios.bundleIdentifier` 3. Enable **Push Notifications** and click **Save** 4. Configure the APNs Key using either method below 5. Rebuild and reinstall the iOS app **Method 1: EAS CLI** Run `pnpm dlx eas-cli credentials` from `apps/native`, select iOS, and create or upload an Apple Push Notifications Key. **Method 2: Expo Dashboard** 1. Open [Apple Developer Keys](https://developer.apple.com/account/resources/authkeys/list) 2. Create a Key with **Apple Push Notifications service (APNs)** enabled 3. Download the `.p8` file and save its Key ID 4. Open the project in [Expo Dashboard](https://expo.dev/) 5. Go to **Project settings → Configuration → Credentials** 6. Select **iOS** and the matching Application Identifier 7. Add or upload a **Push Notifications Key / APNs Key** 8. Upload the `.p8` file, enter the Key ID and Apple Team ID, and save The `.p8` file can be downloaded from Apple only once. Store it securely and do not commit it to Git. See Expo's [Apple credentials permissions guide](https://docs.expo.dev/app-signing/apple-developer-program-roles-and-permissions/). The template Bundle ID is `native.easystarter.dev`. Use your own App ID if you have renamed the app. ### Provisioning Profile is missing push permission If Xcode reports that the Provisioning Profile does not include Push Notifications or `aps-environment`: 1. Enable **Push Notifications** for the App ID in [Apple Developer Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Open the workspace: ```bash open apps/native/ios/EasyStarterNative.xcworkspace ``` 3. Select **EasyStarterNative Target → Signing & Capabilities → + Capability → Push Notifications** 4. Enable **Automatically manage signing** 5. Confirm the Team. The template default is `8622M955TV`; replace it with your own Team when needed 6. Open **Xcode → Settings → Accounts → select the Apple account and Team → Download Manual Profiles** 7. Rebuild the app If the old profile is still selected, run `pnpm dlx eas-cli credentials` again and regenerate the iOS Provisioning Profile. ## 4. Configure Android FCM V1 Follow Expo's [FCM V1 Credentials](https://docs.expo.dev/push-notifications/fcm-credentials/) guide. 1. Create or select a project in [Firebase Console](https://console.firebase.google.com/) 2. Add an Android App whose Package Name matches `android.package` 3. Download `google-services.json` to `apps/native/google-services.json` 4. Add this setting to `apps/native/app.config.ts`: ```ts title="apps/native/app.config.ts" android: { googleServicesFile: "./google-services.json", }, ``` 5. In Firebase, open **Project settings → Service accounts** and generate a private key 6. Run `pnpm dlx eas-cli credentials` from `apps/native` 7. Select **Android → production → Google Service Account → Manage your Google Service Account Key for Push Notifications (FCM V1) → Upload a new service account key** 8. Upload the Service Account JSON 9. Run prebuild again, rebuild, and reinstall the Android app Do not commit the Service Account JSON because it contains a private key. ## 5. Configure the Expo Access Token Open the Push Notifications or Security settings for your Expo project, enable **Enhanced Push Security**, and create an Access Token. Add it to both Server environment files: ```bash title="apps/server/.dev.vars" EXPO_ACCESS_TOKEN=your-expo-access-token ``` ```bash title="apps/server/.env.production" EXPO_ACCESS_TOKEN=your-expo-access-token ``` Before production deployment, upload the secret: ```bash pnpm -F server secrets:bulk:production ``` Do not put this token in an `EXPO_PUBLIC_*` variable or inside the app. If the latest database migrations have not been applied, run: ```bash pnpm db:migrate:local pnpm db:migrate ``` ## 6. Test inside the app Start the Server and a newly built app in separate terminals: ```bash pnpm dev:server ``` ```bash pnpm -F native ios ``` or: ```bash pnpm -F native android ``` Then: 1. Sign in 2. Open **Settings → Notifications** 3. Tap **Enable System Notifications** and allow permission 4. Tap **Send Test Notification** Put the app in the background before sending if you want to verify the operating-system notification UI. Use a Development Build or Release Build, not Expo Go. ## 7. Test on the Expo website Open the [Expo Push Notifications Tool](https://expo.dev/notifications). ### Recipent Enter the current device's Expo Push Token, such as `ExponentPushToken[...]`. To find it, sign in and allow notifications, run `pnpm db:studio:local` for local development or `pnpm db:studio` for production, open `notification_subscription`, and copy `provider_subscription_id` from the current `provider = expo` row. ### Access Token Enter the same value used for `EXPO_ACCESS_TOKEN`. ### Data (JSON string) Use this payload to open Notification Settings when the notification is tapped: ```json {"kind":"self_test","destination":{"type":"notification_settings"}} ``` You can leave Data empty for a display-only test. On Android, use `default` for Channel ID and Sound name. ## Troubleshooting - Missing notification menu: enable notifications, run prebuild, rebuild, and reinstall. - iOS signing error: enable Push Notifications for the App ID and regenerate the Provisioning Profile. - Android delivery error: make sure the uploaded FCM V1 key and `google-services.json` belong to the same Firebase project. - Expo Tool returns `UNAUTHORIZED`: enter the correct Expo Access Token. ## Use another push provider For OneSignal, Braze, Customer.io, CleverTap, or another provider: 1. Create the app in the provider dashboard 2. Add APNs and FCM credentials to that provider 3. Install its Expo plugin or React Native SDK 4. Add its Server credentials 5. Change `notifications.provider` 6. Run `pnpm -F native prebuild`; add `--clean` only if the regular sync fails 7. Rebuild the app and register new device tokens Tokens from different providers cannot be reused. See Expo's [Push Notification Services Guide](https://docs.expo.dev/guides/using-push-notifications-services/). If the provider does not offer an Expo plugin, developer work will usually be required for the Native and Server integrations. # App Administrators and RBAC (http://page.easystarter.dev/docs/mobile/integrations/rbac) EasyStarter's App and Web clients share one global RBAC system. The Server stores either `user` or `admin` on each account, and the App uses the session role to display administrative entry points. ## Configure App administration ### Enable the shared feature switches Update `packages/app-config/src/app-config.ts`: ```ts common: { admin: { // Enable paid-user management paidUsers: { enabled: true, }, // Enable user management and administrative operation history userManagement: { enabled: true, }, }, auth: { // Other authentication settings… rbac: { defaultRole: "user", adminRoles: ["admin"], }, }, } ``` `apps/native/configs/app-config.ts` reads these shared values and exposes `adminUserManagementEnabled` and `adminPaidUsersEnabled`. Keep `defaultRole` set to `"user"` so new accounts do not become administrators automatically. ### Configure the initial administrator on the Server For local development: ```bash title="apps/server/.dev.vars" ADMIN_EMAIL=admin@yourcompany.com ``` For production: ```bash title="apps/server/.env.production" ADMIN_EMAIL=admin@yourcompany.com ``` Upload the production secret: ```bash pnpm -F server secrets:bulk:production ``` `ADMIN_EMAIL` is a server secret. Do not place it in `apps/native/.env*` or any `EXPO_PUBLIC_*` variable. Use the verified email of a real account, not the sender address or `supportEmail`. Only one email is supported. ### Activate the administrator in the App After setting `ADMIN_EMAIL`, redeploy the Server. If the user has already signed in to the App, ask them to sign out and sign in again. The new session contains the `admin` role, and the Profile screen then displays the administrative entry points. ## Administrative entry points in the App `apps/native/app/(tabs)/(profile)/index.tsx` checks authentication, the feature switch, and the permission together: ```tsx const canManageUsers = isAuthenticated && appConfig.adminUserManagementEnabled && hasPermission(user?.role, "user", "list"); const canViewPaidUsers = isAuthenticated && appConfig.adminPaidUsersEnabled && hasPermission(user?.role, "admin", "access"); ``` The App administration area currently includes: - User management - Paid-user management - Credit adjustments and Membership trial grants - Administrative operation history ## Protect App administration screens Do not only hide the Profile menu. The administration route layout should also require `admin:access`: ```tsx if (!isAuthenticated) { return ; } if (!hasPermission(user?.role, "admin", "access")) { return ; } return ; ``` Use `AdminFeatureStack` in each feature's `_layout.tsx` to enforce its feature switch: ```tsx ``` These App checks only control navigation and rendering. Every user, role, ban, credit, and Membership operation must be authorized again by the server-side oRPC procedure. ## Default permissions | Permission | Purpose in the App | | -------------------------- | -------------------------------- | | `admin:access` | Enter administrative areas | | `user:list` | List users | | `user:set-role` | Change user roles | | `user:ban` | Ban and unban users | | `credits:adjust` | Adjust credits | | `membership:grant-trial` | Grant a Membership trial | | `operation:list` | View administrative operations | The `user` role has none of these administrative permissions. The `admin` role has all default administrative permissions. ## Revoke an administrator Removing or changing `ADMIN_EMAIL` does not revoke an existing administrator. Change the old administrator's role back to `user` before updating the server environment variable. After a role change, have that user sign out and sign in again in the App to refresh the local session and administrative entry points. # Alibaba Cloud OSS (Recommended for China) (http://page.easystarter.dev/docs/mobile/integrations/storage/aliyun-oss) ## Alibaba Cloud OSS Storage EasyStarter ships with Alibaba Cloud [Object Storage Service (OSS)](https://www.alibabacloud.com/help/en/oss/) as a built-in storage provider. You can switch between OSS and the default Cloudflare R2 at any time. The server talks to OSS directly through the OSS REST API V4 with `OSS4-HMAC-SHA256` signing — no Node.js SDK is required, so it runs natively on the Cloudflare Workers runtime. If your product is mostly used inside mainland China, Alibaba Cloud OSS usually gives more stable latency and cheaper egress than Cloudflare R2. It also reuses the same RAM AccessKey as the [Alibaba Cloud phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth) integration, which keeps operations simple. | Item | Current setup | | --- | --- | | Upload / download / list / delete | OSS REST API V4 with `OSS4-HMAC-SHA256` signing | | Server provider | `apps/server/src/storage/providers/aliyun-oss.ts` | | Provider registration | `apps/server/src/storage/index.ts` | | Provider switch | `common.storage.provider` in `packages/app-config/src/app-config.ts` | | Public access path | `${SERVER_URL}/api/storage/aliyun-oss/` (proxied by the server; the bucket itself stays private) | The existing `avatar` and `attachment` upload types, MIME allowlists, and size limits are provider-agnostic. Switching to OSS does not require any change to business code. ## Required Environment Variables ```bash # Shared with the Alibaba Cloud phone sign-in integration ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= # OSS-specific ALIYUN_OSS_BUCKET= ALIYUN_OSS_REGION= ALIYUN_OSS_ENDPOINT= ``` What each variable means: | Variable | Meaning | Example | | --- | --- | --- | | `ALIBABA_CLOUD_ACCESS_KEY_ID` | RAM user AccessKey ID, the long-term credential used by the server | `LTAI5tXXXXXXXXXXXXXXXXX` | | `ALIBABA_CLOUD_ACCESS_KEY_SECRET` | RAM user AccessKey Secret, shown only once on creation | `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` | | `ALIYUN_OSS_BUCKET` | OSS bucket name, without any domain | `your-app-bucket` | | `ALIYUN_OSS_REGION` | Region ID of the bucket, used as the region scope when signing | `cn-hangzhou` | | `ALIYUN_OSS_ENDPOINT` | OSS access domain — **must not include the bucket name or the protocol** | `oss-cn-hangzhou.aliyuncs.com` | `ALIYUN_OSS_ENDPOINT` must be the region-level public endpoint, e.g. `oss-cn-hangzhou.aliyuncs.com`. If you set it to `your-app-bucket.oss-cn-hangzhou.aliyuncs.com`, the provider will prepend the bucket again and produce an invalid host. If you already configured `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` for [phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth), you can reuse the same AccessKey — just grant the existing RAM user additional OSS permissions. ### Activate Object Storage Service Make sure your Alibaba Cloud account has OSS activated and identity verification completed. Product page: [Alibaba Cloud Object Storage Service (OSS)](https://www.alibabacloud.com/product/oss). Click **Buy Now** / **Activate Now** at the top of the page to enable the service. ### Create an OSS bucket Official docs: [Create a bucket](https://www.alibabacloud.com/help/en/oss/user-guide/create-a-bucket-4) 1. Log in to the [OSS Console](https://oss.console.aliyun.com/) 2. Click **Buckets** → **Create Bucket** 3. Enter a **Bucket name**, e.g. `your-app-bucket` (globally unique, 3-63 characters, lowercase letters, digits, and hyphens only) 4. Choose a **Region**, e.g. `China (Hangzhou)`, which maps to the region ID `cn-hangzhou` 5. Keep **ACL** as the default **Private** — files are served through a server-side proxy, the bucket does not need public access 6. Accept defaults for the rest and click **OK** Record the following: - Bucket name → `ALIYUN_OSS_BUCKET` - Region ID (the part after `oss-` in the bucket overview, e.g. `cn-hangzhou` in `oss-cn-hangzhou`) → `ALIYUN_OSS_REGION` - Public endpoint (the **Endpoint (External)** field on the bucket overview, e.g. `oss-cn-hangzhou.aliyuncs.com`) → `ALIYUN_OSS_ENDPOINT` ### Grant the RAM user OSS permissions Use a RAM user AccessKey instead of an Alibaba Cloud root account AccessKey. If you already created a RAM user for [phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth), simply attach an additional policy to the same user. 1. Log in to the [Alibaba Cloud RAM Console](https://ram.console.aliyun.com/) 2. Go to **Identities** → **Users** and select the target RAM user 3. Open **Permissions** → **Grant Permission** 4. Set **Resource Scope** to **Account**, search and check the system policy **`AliyunOSSFullAccess`** under **Policy**, then click **OK** to grant the permission ![Select the AliyunOSSFullAccess system policy in the RAM grant-permission panel](/images/docs/aliyun-oss-ram-policy.png) This is the approach Alibaba Cloud officially recommends. Once the system policy is attached, the RAM user can read and write OSS objects. ### Create or reuse the AccessKey Official docs: [Create an AccessKey pair](https://www.alibabacloud.com/help/en/ram/user-guide/create-an-accesskey-pair) If you do not have an AccessKey yet: 1. Open the **Authentication** or **AccessKey** tab on the RAM user detail page 2. Click **Create AccessKey** and complete the security challenge 3. Copy and save immediately: - `AccessKey ID` → `ALIBABA_CLOUD_ACCESS_KEY_ID` - `AccessKey Secret` → `ALIBABA_CLOUD_ACCESS_KEY_SECRET` The `AccessKey Secret` is shown only once. If you lose it, you must disable the old AccessKey and create a new one. If you already configured a RAM user AccessKey for phone sign-in, reuse it instead of creating another AccessKey for the same user. ### Switch the storage provider In `packages/app-config/src/app-config.ts`, change `common.storage.provider` from `"r2"` to `"aliyun-oss"`: ```ts title="packages/app-config/src/app-config.ts" storage: { enabled: true, provider: "aliyun-oss", // change from "r2" to "aliyun-oss" publicPath: "/api/storage", // ...rest unchanged }, ``` After this change, every `avatar` / `attachment` upload, download, list, and delete routes through the OSS provider automatically. Business code, upload components, and Better Auth avatar logic do not need to change. ### Fill in local and production environment variables For local development, add to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` For production deployment, add to `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` After switching to OSS, `R2_PUBLIC_URL` is no longer needed and can be removed from both files. The `r2_buckets` binding (`STORAGE`) in `apps/server/wrangler.jsonc` is unused as well — you can keep it so you can switch back, or remove it. ### Push secrets to production Before deploying to Cloudflare Workers, push the secrets: ```bash pnpm -F server secrets:bulk:production ``` After a successful push, the Worker runtime reads these values via `env.ALIBABA_CLOUD_ACCESS_KEY_ID`, `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`, `env.ALIYUN_OSS_BUCKET`, `env.ALIYUN_OSS_REGION`, and `env.ALIYUN_OSS_ENDPOINT`. Re-run this command whenever any of those values change — you do not need to redeploy code just because a secret changed. ### Verify uploads locally Start the server and the web client: ```bash pnpm dev:server pnpm dev:web ``` Sign in and upload an avatar from the profile page. The frontend posts the file to `/api/storage/upload`, the server calls the OSS provider's `put` method and writes the file under `avatars//...`, and returns a public URL similar to: ``` http://localhost:3001/api/storage/aliyun-oss/avatars//.png ``` Reads are proxied back through `/api/storage/aliyun-oss/`, so the bucket itself stays private. You can confirm the object in the OSS Console **Files** view, and opening the public URL above in a browser should render the image. # Object Storage (http://page.easystarter.dev/docs/mobile/integrations/storage) ## Object Storage EasyStarter uses [Cloudflare R2](https://developers.cloudflare.com/r2/) as its object storage service for uploading and managing user files. Two upload types are supported out of the box: | Upload type | Description | Size limit | | --- | --- | --- | | `avatar` | User profile picture | 5 MB | | `attachment` | Attachments (images, PDFs, plain text) | 25 MB | ### Create an R2 Bucket Official docs: [R2 Getting started](https://developers.cloudflare.com/r2/get-started/) **Option 1: via Cloudflare Dashboard** 1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **R2 Object Storage** 3. Click **Create bucket** 4. Enter a bucket name, e.g. `your-app-bucket` 5. Choose a location (Automatic is recommended) 6. Click **Create bucket** **Option 2: via Wrangler CLI** ```bash pnpm wrangler r2 bucket create your-app-bucket ``` ### Configure wrangler.jsonc Fill in your bucket name in the `r2_buckets` field of `apps/server/wrangler.jsonc`: ```jsonc title="apps/server/wrangler.jsonc" "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-app-bucket" } ], ``` - `binding` must stay `STORAGE` — this is the variable name used to access R2 inside the Worker. Do not change it. - `bucket_name` should be the name of the bucket you created in R2. ### Enable public access and get R2_PUBLIC_URL After uploading files, you need a public URL to serve them. The recommended approach is to use R2's built-in **Public Access** feature. **Enable via Cloudflare Dashboard (recommended)** 1. Open your R2 bucket detail page 2. Click the **Settings** tab 3. Under **Public Access**, click **Allow Access** 4. A public URL will be generated automatically in the format: `https://pub-xxxxxxxx.r2.dev` This URL is your `R2_PUBLIC_URL`. **Custom domain (optional)** You can also bind a custom domain under bucket Settings → Custom Domains, e.g. `https://cdn.yourdomain.com`. Use the custom domain as `R2_PUBLIC_URL` after binding. ### Set the R2_PUBLIC_URL environment variable `R2_PUBLIC_URL` must be added to two environment variable files: **Local development** (`apps/server/.dev.vars`): ```bash title="apps/server/.dev.vars" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` **Production deployment** (`apps/server/.env.production`): ```bash title="apps/server/.env.production" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` `.env.production` is used to bulk-push secrets to Cloudflare Workers via `pnpm run secrets:bulk:production`. It does not participate in the build directly. ### Configure storage parameters (optional) Storage parameters are defined in `common.storage` inside `packages/app-config/src/app-config.ts` and can be adjusted as needed: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "r2", publicPath: "/api/storage", // API path prefix for serving files keyPrefixes: { avatar: "avatars", // Key prefix for avatar files attachment: "attachments", // Key prefix for attachment files }, fallbackPrefix: "files", // Fallback prefix for unclassified uploads allowedTypes: { avatar: ["image/jpeg", "image/png", "image/gif", "image/webp"], attachment: ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"], }, maxFileSizes: { avatar: 5 * 1024 * 1024, // 5 MB attachment: 25 * 1024 * 1024, // 25 MB }, }, ``` ## Extending to other storage providers EasyStarter's storage layer is built around the `StorageProvider` interface. Plugging in any storage service (e.g. [AWS S3](https://aws.amazon.com/s3/), [MinIO](https://min.io/)) takes four steps. ### Step 1: Register the provider key Suppose using S3 as an example. Add the new provider key to `SUPPORTED_STORAGE_PROVIDERS` in `packages/app-config/src/types.ts`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_STORAGE_PROVIDERS = ["r2", "s3"] as const; ``` ### Step 2: Implement the provider Create a new file in `apps/server/src/storage/providers/` that implements the `StorageProvider` interface: ```ts title="apps/server/src/storage/providers/s3.ts" import type { StorageProvider } from "../types"; export function createS3StorageProvider({ client, bucket }: { client: S3Client; bucket: string; }): StorageProvider { return { async put(key, data, options) { // Call the S3 SDK to upload }, async get(key) { // Call the S3 SDK to download }, async head(key) { // Call the S3 SDK to get metadata }, async delete(key) { // Call the S3 SDK to delete }, }; } ``` ### Step 3: Register it in the storage providers map Add the new provider to the `providers` map in `apps/server/src/storage/index.ts`: ```ts title="apps/server/src/storage/index.ts" import { createS3StorageProvider } from "./providers/s3"; const providers: Record = { r2: createR2StorageProvider({ bucket: storage }), s3: createS3StorageProvider({ client: s3Client, bucket: "your-bucket" }), }; ``` ### Step 4: Switch the configuration Update `storage.provider` in `packages/app-config/src/app-config.ts` to the new provider key: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "s3", // switch to the new provider // ...other fields remain unchanged }, ``` Once done, all file upload, download, and delete operations will automatically route through the new provider — no changes to business logic required. # Mobile Project Structure (http://page.easystarter.dev/docs/mobile/project-structure) ## Shared Monorepo Structure EasyStarter is a `pnpm workspace` monorepo managed by `Turborepo`. ```text apps/ web/ Web client native/ Mobile client server/ Hono API on Cloudflare Workers config-ui/ Internal configuration tool packages/ app-config/ Shared business configuration api-client/ Shared API client contracts i18n/ Shared locale resources shared/ Cross-platform utilities and types ``` ## Shared Design Rules - Backend capability lives in `apps/server` - Cross-platform configuration stays in `packages/*` - Web and Mobile each keep their own UI and navigation - Shared business rules should not be duplicated between clients # Complete Video Tutorial (http://page.easystarter.dev/docs/mobile/video-tutorial) # Skills (http://page.easystarter.dev/docs/web/ai-prompts) Usage [#usage] Open your AI coding assistant in the EasyStarter project root, then enter a Skill name followed by your request: ```text $easystarter-web-quick-launch Launch my Web app with the recommended minimal setup. ``` Web [#web] | Task | Command | | --------------------------- | ------------------------------------------------------------------------------------- | | Quick launch | `$easystarter-web-quick-launch Launch my Web app with the recommended minimal setup.` | | Start local development | `$easystarter-web-dev-start Start Web and Server locally.` | | Configure Cloudflare and D1 | `$easystarter-web-cloudflare-d1 Configure Cloudflare and the D1 database.` | | Configure email | `$easystarter-web-resend-email Configure Resend email.` | | Configure authentication | `$easystarter-web-auth Configure Web authentication.` | | Configure phone login | `$easystarter-web-aliyun-phone-login Configure Alibaba Cloud phone login.` | | Configure storage | `$easystarter-web-storage Configure Cloudflare R2 storage.` | | Configure Stripe | `$easystarter-web-stripe-payments Configure Stripe payments.` | | Configure Creem | `$easystarter-web-creem-payments Configure Creem payments.` | | Deploy Server | `$easystarter-web-deploy-server Deploy Server to Cloudflare Workers.` | | Deploy Web | `$easystarter-web-deploy-web Deploy Web to Cloudflare Workers.` | | Customize the theme | `$easystarter-web-theme Customize the Web theme.` | | Customize the landing page | `$easystarter-web-landing-page Customize the landing page.` | | Configure analytics | `$easystarter-web-analytics Configure GA4 and OpenPanel.` | | Configure credits | `$easystarter-web-credits Configure the Web credit system.` | Feature Development [#feature-development] | Task | Command | | -------------------- | ---------------------------------------------------------------- | | Add an API route | `$easystarter-api-route Create an API route for [feature].` | | Add a database table | `$easystarter-db-schema Create a database schema for [feature].` | | Add a UI component | `$easystarter-component Add a [component] component.` | | Add a form page | `$easystarter-form-page Create a form page for [feature].` | | Add a data table | `$easystarter-data-table Create a data table for [resource].` | | Add translations | `$easystarter-i18n Add translations for [feature].` | # Landing Page (http://page.easystarter.dev/docs/web/config/landing-page) ## Landing Page Configuration The landing page is composed of independent blocks, each mapped to a React component. The block list is freely composable — a default is defined in code, and users can rearrange it in-app. --- ## Available Blocks All blocks are registered in: ``` apps/web/src/configs/landing-page-component/landing-page-component-registry.tsx ``` | Key | Label | Type | | --- | --- | --- | | `hero-section-23` | Shadcn Hero 23 | hero | | `hero-section-03` | Shadcn Hero 03 | hero | | `tailark-hero` | Tailark Hero | hero | | `features-section-21` | Shadcn Features 21 | features | | `tailark-logo-cloud` | Tailark Logo Cloud | logo-cloud | | `tailark-features` | Tailark Features | features | | `tailark-integrations` | Tailark Integrations | integrations | | `tailark-content` | Tailark Content | content | | `tailark-stats` | Tailark Stats | stats | | `tailark-pricing` | Tailark Pricing | pricing | | `tailark-faqs` | Tailark FAQs | faqs | | `tailark-call-to-action` | Tailark Call To Action | call-to-action | | `tailark-testimonials` | Tailark Testimonials | testimonials | > **Note**: Only one block per `type` is rendered. If both `hero-section-23` and `hero-section-03` are in the list, only the one with the higher sort priority is shown. --- ## Changing the Default Block List The default landing page blocks are defined in: ```typescript title="apps/web/src/configs/web-config.ts" const defaultLandingPageComponents = [ "hero-section-23", "tailark-logo-cloud", "features-section-21", "tailark-integrations", "tailark-content", "tailark-stats", "tailark-pricing", "tailark-faqs", "tailark-call-to-action", "tailark-testimonials", ] as const satisfies readonly LandingPageComponentKey[]; ``` Edit this array to change the landing page structure new users see: - **Reorder**: move key names up or down — display order follows - **Remove**: delete the key name from the array - **Add**: insert a registered key name into the array --- ## Storage Key When a user customizes the landing page in the app, the selection is saved to `localStorage`: | Key | Content | | --- | --- | | `{AppName}-landing-page-components` | JSON array of active block key names | If the stored list is empty or all entries are invalid, it falls back to `defaultLandingPageComponents`. --- ## Adding a Custom Block ### Create the React component Create a new component directory under `apps/web/src/components/landing-page/`: ``` apps/web/src/components/landing-page/ └── my-section/ └── my-section.tsx ``` ### Register in the component registry Add the new entry to `landing-page-component-registry.tsx`: ```typescript title="apps/web/src/configs/landing-page-component/landing-page-component-registry.tsx" import MySection from "@/components/landing-page/my-section/my-section"; export const landingPageComponentMap = { // existing blocks... "my-section": () => , }; ``` ### Add metadata Add the label and group to `LANDING_PAGE_COMPONENTS` in `landing-page-component-config.ts`: ```typescript title="apps/web/src/configs/landing-page-component/landing-page-component-config.ts" export const LANDING_PAGE_COMPONENTS = { // existing entries... "my-section": { label: "My Section", group: "Custom", type: "my-type" }, }; ``` ### Add to default list (optional) To include the new block by default, add its key to `defaultLandingPageComponents` in `web-config.ts`. # Theme Configuration (http://page.easystarter.dev/docs/web/config/theme) ## Web Theme Configuration The Web app uses Base UI components with the default shadcn/ui light and dark tokens in `apps/web/src/styles/index.css`. There is no custom theme-preset picker. ## Light / Dark Mode Users can switch **Light**, **Dark**, or **System** from the theme toggle in the header. The choice is stored in `localStorage` and applied as a `.light` or `.dark` class on ``. Default colors live in `:root` (light) and `.dark` (dark). Keep the `@theme inline` block so Tailwind classes such as `bg-background` and `text-foreground` stay mapped. ## Change the Default Colors If you need a different palette, edit the CSS variables in `apps/web/src/styles/index.css` for both `:root` and `.dark`. Do not add a runtime preset system. # Create a Project with CLI (http://page.easystarter.dev/docs/web/create-project) After you purchase EasyStarter and accept the GitHub collaborator invite, use this command to create **your** project. You do not clone the template by hand. The template repository is private. Accept the collaborator invite first. If the command cannot download the template, the invite is still pending. ### Install tools - [`Node.js 22+`](https://nodejs.org/) - [`pnpm 9+`](https://pnpm.io/) - [`git`](https://git-scm.com/) ### Create the project In an empty directory: ```bash pnpm create easystarter my-app ``` or: ```bash npx create-easystarter my-app ``` Replace `my-app` with your project name. It must be lowercase kebab-case (`acme-app`, not `Acme App`). The command downloads EasyStarter, names the project after your choice, writes local env files, installs dependencies, and can start the dev servers. ### Answer the setup questions The wizard asks about **your product**. Pick what you need now — you can change these later in `packages/app-config`. | Question | What to choose | |---|---| | App display name | The name users see, for example `Acme` | | Auth methods | At least one: `email-password`, `email-otp`, `github`, `google`, `apple`, `sms` | | Web payments | `none`, `stripe`, `creem`, or `waffo` | | Native payments | `none` or `revenuecat` | | Email provider | `cloudflare` or `resend` | | Enable credits | Yes only if you sell credits | | Install dependencies | Yes | | Initialize git | Yes | Default choices are email/password login, no payments, Cloudflare email, and no credits. After that it migrates the local database and starts Web + Server. Skip the questions and use those defaults: ```bash pnpm create easystarter my-app -y ``` Pass flags if you already know the stack, for example: ```bash pnpm create easystarter my-app \ --app-name "Acme" \ --auth email-password github \ --payments stripe \ --native-payments revenuecat \ --email resend \ --no-dev ``` `--no-dev` creates the project without starting the servers. `--auth` accepts one or more methods. ### Open the local app When create finishes (and you did not pass `--no-dev`): | App | URL | |---|---| | Web | [http://localhost:3000](http://localhost:3000) | | Server | [http://localhost:3001](http://localhost:3001) | | Extension | [http://localhost:3002](http://localhost:3002) | If the servers are not running: ```bash cd my-app pnpm dev:web+server ``` Env files are already created. Do not copy the `.example` files over them — that overwrites `BETTER_AUTH_SECRET`. If you enabled GitHub, Google, Apple, SMS, Resend, Stripe, Creem, Waffo, or RevenueCat, the command prints the extra keys those features still need. Add them before you use those integrations. ### Connect Cloudflare Create only sets up the local project. It does **not** create Cloudflare D1 or R2, and it does **not** deploy. When you are ready to attach your Cloudflare account (needed for remote database, object storage, and deploy): ```bash cd my-app pnpm exec create-easystarter init ``` or: ```bash npx create-easystarter init ``` The command opens a Cloudflare login if needed, then creates or reuses `{project}-db` and `{project}-bucket`, and writes the IDs into your project. Local development can run before this step. To also apply remote D1 migrations: ```bash pnpm exec create-easystarter init --migrate ``` Remote migrate needs a Cloudflare API token with D1 edit permission. Create one at [API Tokens](https://dash.cloudflare.com/profile/api-tokens). You can skip the token during `init` and add `CLOUDFLARE_API_TOKEN` later, then run `pnpm db:migrate`. You can also pass production URLs: ```bash pnpm exec create-easystarter init \ --website-url https://example.com \ --server-url https://api.example.com ``` # Web Data Access (http://page.easystarter.dev/docs/web/database) ## Database The project uses [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) as its database layer. ### Create the D1 database See official docs: [D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) Option 1: Cloudflare Dashboard 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **Storage & databases → D1 SQL database** 3. Click **Create database** 4. Enter a database name, for example `easysaas-db` 5. Choose a location if needed 6. Click **Create** Once created, copy the `database_id` from the database details page. Option 2: Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` On success, Wrangler outputs a D1 binding snippet that contains the `database_id`. ### Configure the D1 database ID After obtaining your `database_id`, add it to the following two locations. Environment variables: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` For how to obtain `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, see [Cloudflare Integration](/docs/web/integrations/cloudflare). Set `database_id` in: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` and: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler config: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` That means `apps/server/wrangler.jsonc` must use the same `database_id`. ### Run the local database workflow For local database development, use these three commands in this order: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` Generate migration files from `apps/server/src/db/schema`. `pnpm db:migrate:local` Apply the generated migrations to the local D1 database. This command already handles local D1 initialization. `pnpm db:studio:local` Open the local D1 visual UI so you can inspect tables and data. # Deploy Server (http://page.easystarter.dev/docs/web/deploy-server) ## Deploy Server (Cloudflare Workers) EasyStarter's server is built with [Hono](https://hono.dev/) and runs on [Cloudflare Workers](https://workers.cloudflare.com/), using D1 as the database and R2 as object storage. Before starting, confirm that the following prerequisites are in place: - Cloudflare credentials ready (see [Cloudflare Integration](/docs/web/integrations/cloudflare)) - D1 database created and **Database ID** on hand (see [Database](/docs/web/integrations/database)) - R2 bucket created and **bucket name** on hand (see [Storage](/docs/web/integrations/storage)) EasyStarter supports two deployment methods — choose the one that fits your workflow: | Method | Best for | | --- | --- | | **Option 1: Local CLI** | Quick launch, one-off deploys, full manual control | | **Option 2: GitHub auto-deploy** | Continuous delivery, team collaboration, deploy on push | --- ## Option 1: Local CLI deploy Authenticate Wrangler locally, then run the deploy commands manually. ```bash npx wrangler login ``` ## Environment variable overview Server variables live in three separate places: | Type | Location | Description | | --- | --- | --- | | **Public config** | `apps/server/wrangler.jsonc` → `vars` | Non-sensitive values — stored in plain text, deployed with code | | **Local dev** | `apps/server/.dev.vars` | Auto-loaded by `wrangler dev`, never deployed | | **Production secrets** | `apps/server/.env.production` | Pushed to Workers Secrets via `wrangler secret bulk`, never in build output | > Never commit `.env.production` to Git. Add `.dev.vars` to `.gitignore` as well. ### Update `apps/server/wrangler.jsonc` Fill in your Worker name, D1 Database ID, R2 bucket name, and public variables: ```jsonc title="apps/server/wrangler.jsonc" { "name": "your-server-worker", // Worker name — globally unique, determines default URL "main": "src/index.ts", "compatibility_date": "2025-06-15", "compatibility_flags": ["nodejs_compat"], "d1_databases": [ { "binding": "DB", "database_name": "your-db-name", // D1 database name (arbitrary, for your reference) "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // D1 Database ID (UUID) } ], "vars": { "NODE_ENV": "production", "WEBSITE_URL": "https://your-app.com", // Public URL of the web app "SERVER_URL": "https://your-server.workers.dev", // Public URL of this server Worker "GITHUB_CLIENT_ID": "your-github-client-id", // Public — safe to put in vars "GOOGLE_CLIENT_ID": "your-google-client-id" // Public — safe to put in vars }, "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-bucket-name" // R2 bucket name } ] } ``` | Field | Description | | --- | --- | | `name` | Worker name — default URL is `https://..workers.dev` | | `database_id` | UUID of the D1 database | | `vars.WEBSITE_URL` | Web app URL — used by Better Auth for callback URLs and email links | | `vars.SERVER_URL` | This server Worker's URL — used by Better Auth config and CORS | | `bucket_name` | Must match the R2 bucket name in the Cloudflare Dashboard | ### Prepare production secrets (`.env.production`) Copy the production env template first (if the file doesn't exist yet): ```bash cp apps/server/.env.production.example apps/server/.env.production ``` Then fill `apps/server/.env.production` with all sensitive variables. This file is never included in the build — it is only used by `wrangler secret bulk` in the next step. ```bash title="apps/server/.env.production" // Example environment variables: (Refer to apps/server/.env.production.example for actual content) BETTER_AUTH_SECRET=your-better-auth-secret GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret R2_PUBLIC_URL=https://your-bucket.your-subdomain.r2.dev REVENUECAT_API_KEY=your-revenuecat-api-key STRIPE_SECRET_KEY=your-stripe-secret-key STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret ``` **Key notes:** - Variables for unused integrations (e.g. RevenueCat) can be left empty or removed ### Deploy the Worker ```bash pnpm deploy:server ``` This compiles `apps/server/src/index.ts` and publishes it to Cloudflare Workers. On success: ``` Deployed your-server-worker triggers: https://your-server-worker.your-subdomain.workers.dev ``` Save this URL — you'll need it when configuring the web app and updating `SERVER_URL`. ### Push secrets Encrypt and store all variables from `.env.production` in Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` This runs `wrangler secret bulk .env.production`. Each value is encrypted at rest on Cloudflare's side and never appears in deployed code or logs. > Secrets and code deployments are independent. To update a sensitive variable, re-run this command — no redeploy needed. ### Run database migrations Apply the database schema to Cloudflare D1. The `pnpm db:migrate` command uses drizzle-kit's D1 HTTP driver, which requires these three values in `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= # Your Cloudflare account ID CLOUDFLARE_API_TOKEN= # API token with D1 Edit permission CLOUDFLARE_D1_DATABASE_ID= # D1 database UUID ``` Then run: ```bash pnpm db:migrate ``` This creates all required tables in the production D1 database (users, sessions, subscriptions, billing, etc.). > After every schema change: run `pnpm db:generate` to produce a new migration file, then `pnpm db:migrate` to apply it to production. ## Verify the deployment In the Cloudflare Dashboard, go to **Workers & Pages** → select your Worker → **Logs** to view live request logs and confirm the service is responding correctly. --- ## Option 2: GitHub auto-deploy Connect your GitHub repository to Cloudflare so every push to the target branch automatically triggers a build and deploy — no local commands needed. ### Connect your GitHub repository 1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. Click **Create** → **Workers** → **Connect to Git** 3. Authorize Cloudflare to access your GitHub account and select your repository 4. Choose the deployment branch (usually `master`) ### Push secrets After connecting the repository but before the first build fires, push all secrets to Cloudflare from your local machine so the Worker has everything it needs on startup: ```bash pnpm -F server secrets:bulk:production ``` This runs `wrangler secret bulk .env.production`, encrypting every variable in `apps/server/.env.production` into Worker Secrets. > Secrets and code deploys are independent. You only need to re-push when a secret value changes — not on every code update. ### Enter the build configuration Fill in the following settings on the build configuration page: | Field | Value | | --- | --- | | **Root directory** | `/` | | **Build command** | `pnpm --filter server build` | | **Deploy command** | `pnpm --filter server run deploy` | | **Version command** | `pnpm --filter server run deploy` | > Root directory is `/` because this is a monorepo — pnpm workspaces must resolve dependencies from the repo root. ### Disable non-production branch deployments After saving the build configuration, **disable non-production branch builds**. This step is mandatory. The server Worker uses a fixed name — if Cloudflare builds and deploys a non-production branch (e.g. `feature/x`), it overwrites the same Worker, pointing production traffic at unfinished code and potentially running unintended database migrations against the live D1 database. ### Run database migrations Auto-deploy does **not** run database migrations automatically. After the first deploy, run this manually from your local machine: ```bash pnpm db:migrate ``` Make sure `apps/server/.dev.vars` has these values filled in: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` > For every future schema change: run `pnpm db:generate` locally to generate a migration file, then `pnpm db:migrate` to apply it to the production D1. Once configured, every push to the target branch automatically triggers a new build and deployment. View the status and logs for each deploy under **Workers & Pages → your Worker → Deployments**. --- ## Custom domain (Recommended) 1. Go to **Workers & Pages** → select your server Worker → **Settings → Domains & Routes** 2. Click **Add Custom Domain** and enter a domain hosted on Cloudflare (e.g. `api.yourdomain.com`) 3. Once the domain is bound, update the related config as described below ### Why these two fields must be updated `SERVER_URL` and `WEBSITE_URL` are not ordinary environment variables — they live in the `vars` block of `wrangler.jsonc` and are **compiled into the Worker bundle** at deploy time. Changing them requires a redeploy to take effect. These two fields are critical for the authentication system: | Field | Used for | | --- | --- | | `SERVER_URL` | Better Auth `baseURL`; OAuth callback URLs (`/api/auth/callback/github`, etc.); cookie `domain` and `secure` policy | | `WEBSITE_URL` | Better Auth `trustedOrigins` (CORS allowlist); redirect links in transactional emails | If either value doesn't match the actual domain, OAuth callbacks will return 404, cross-origin requests will be blocked by CORS, and session cookies won't be set. ### Files to update **① `apps/server/wrangler.jsonc`** ```jsonc "vars": { "WEBSITE_URL": "https://your-app.com", // final domain of the web app "SERVER_URL": "https://api.yourdomain.com" // the custom domain you just bound } ``` **② `apps/web/wrangler.jsonc`** The web app also holds the server address for direct API calls from the frontend: ```jsonc "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com" // must match SERVER_URL above } ``` **③ OAuth app settings** If you changed `SERVER_URL`, update the callback URLs in each OAuth provider: - **GitHub**: Settings → Developer settings → OAuth Apps → update **Authorization callback URL** to `https://api.yourdomain.com/api/auth/callback/github` - **Google**: Google Cloud Console → Credentials → OAuth 2.0 Client → update **Authorized redirect URIs** ### Redeploy to apply the changes Both apps have changes, so deploy both: ```bash pnpm deploy:server pnpm deploy:web ``` > `vars` are static config compiled into the bundle — they are not Secrets. Any change to `vars` in `wrangler.jsonc` requires a redeploy. Running `wrangler secret bulk` alone will not update these values. # Deploy Web (http://page.easystarter.dev/docs/web/deploy-web) ## Deploy Web (Cloudflare Workers) EasyStarter's web app is built with [TanStack Start](https://tanstack.com/start/latest) and runs in SSR mode on Cloudflare Workers. The Web Worker connects to the Server Worker via [Service Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) — no public network hop required. **You must complete the [Server deployment](/docs/web/deploy-server) before deploying the web app.** Service Bindings require the Server Worker to already exist. EasyStarter supports two deployment methods — choose the one that fits your workflow: | Method | Best for | | --- | --- | | **Option 1: Local CLI** | Quick launch, one-off deploys, full manual control | | **Option 2: GitHub auto-deploy** | Continuous delivery, team collaboration, deploy on push | --- ## Option 1: Local CLI deploy Authenticate Wrangler locally, then run the deploy commands manually. ```bash npx wrangler login ``` ## Environment variable overview All web app variables are `VITE_`-prefixed **public config** — Vite injects them into the client bundle at build time. For local CLI deploys, put them in `wrangler.jsonc` `vars`; for CI / remote deploys, also configure them in the platform's build environment variables. | Type | File | Description | | --- | --- | --- | | **Local development** | `apps/web/.env.development` | Auto-loaded by `wrangler dev`, points to localhost | | **Local production deploy** | `apps/web/wrangler.jsonc` → `vars` | Compiled into the Worker bundle at deploy time | | **CI / remote deploy** | CI build environment variables / secrets | Add every variable from `apps/web/.env.production` | > The web app does not use `wrangler secret bulk`. `apps/web/.env.production` can be used as the production build variable checklist; for CI / remote deploys, add those values to the platform's build environment variables or secrets. If the production variable checklist doesn't exist yet, copy the template first: ```bash cp apps/web/.env.production.example apps/web/.env.production ``` Then fill `apps/web/.env.production` with the production `VITE_` variables. This file is not used by `wrangler secret bulk`; it is mainly a checklist for syncing values into CI / remote deploy build environment variables. ### Update `apps/web/wrangler.jsonc` Fill in the Worker name, Server Service Binding, and public variables: ```jsonc title="apps/web/wrangler.jsonc" { "name": "your-web-worker", // Web Worker name — must be globally unique "compatibility_date": "2025-09-02", "compatibility_flags": ["nodejs_compat"], "main": "@tanstack/react-start/server-entry", "assets": { "not_found_handling": "single-page-application" }, "services": [ { "binding": "API_SERVICE", "service": "your-server-worker" // must exactly match the name in Server's wrangler.jsonc } ], "vars": { "VITE_SERVER_URL": "https://your-server-worker.your-subdomain.workers.dev", "VITE_APP_URL": "https://your-web-worker.your-subdomain.workers.dev" } } ``` | Field | Description | | --- | --- | | `name` | Web Worker name — determines the default access URL | | `services[0].service` | **Must exactly match the Server's `name`** — the Service Binding won't work otherwise | | `vars.VITE_SERVER_URL` | Server address — used by the oRPC client and Better Auth client | | `vars.VITE_APP_URL` | Web app's own address — used for SEO canonical URLs, OAuth callbacks, etc. | ### Deploy the Web Worker ```bash pnpm deploy:web ``` Wrangler runs `vite build` to produce the SSR output, then publishes it to Cloudflare Workers. On success, the console prints the Worker URL: ``` Deployed your-web-worker triggers: https://your-web-worker.your-subdomain.workers.dev ``` ## Verify the deployment Visit the Web Worker URL and confirm the following work correctly: - [ ] Homepage loads - [ ] User sign-up / sign-in (email + OAuth) - [ ] Payment checkout flow (requires Stripe to be configured) - [ ] File uploads (requires R2 to be configured) --- ## Option 2: GitHub auto-deploy Connect your GitHub repository to Cloudflare so every push to the target branch automatically triggers a build and deploy. > The web app does not use Workers Secrets, but GitHub auto-deploy still needs build configuration and build environment variables. ### Connect your GitHub repository 1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. Click **Create** → **Workers** → **Connect to Git** 3. Authorize Cloudflare to access your GitHub account and select your repository 4. Choose the deployment branch (usually `master`) ### Enter the build configuration Fill in the following settings on the build configuration page: | Field | Value | | --- | --- | | **Root directory** | `/` | | **Build command** | `pnpm --filter web build` | | **Deploy command** | `pnpm --filter web run deploy` | | **Version command** | `pnpm --filter web run deploy` | > Root directory is `/` because this is a monorepo — pnpm workspaces must resolve dependencies from the repo root. For CI / remote deploys, do not rely on a local `apps/web/.env.production` file. Add every variable from `apps/web/.env.production` to the CI build environment; when the platform separates variables and secrets, put public config in build variables and sensitive values in build secrets. ![Cloudflare build environment variables entry](/images/cloudflare-build-variables.png) ### Disable non-production branch deployments After saving the build configuration, **disable non-production branch builds**. This step is mandatory. The web Worker uses a fixed name — a non-production branch build overwrites the same Worker, replacing the live web app with unfinished code. Because the web Worker is bound to the server Worker via Service Bindings, a mismatched deploy can also break the API connection. Once configured, every push to the target branch automatically triggers a new build and deployment. View the status and logs for each deploy under **Workers & Pages → your Worker → Deployments**. --- ## Custom domain (recommended) 1. Go to Cloudflare Dashboard → **Workers & Pages** → select your Web Worker 2. Go to **Settings → Domains & Routes** 3. Click **Add Custom Domain** and enter your domain (must be hosted on Cloudflare), e.g. `app.yourdomain.com` 4. Once bound, update the related config as described below ### Why multiple files need to be updated `VITE_APP_URL` and `VITE_SERVER_URL` live in `wrangler.jsonc` `vars` and are compiled into the Worker bundle at build time. Changing them requires a redeploy to take effect. The server also references `WEBSITE_URL` (Better Auth trusted origins). If the web domain changes, the server config must be updated too — otherwise auth requests will be blocked by CORS. ### Files to update **① `apps/web/wrangler.jsonc`** ```jsonc "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com", // matches the server custom domain "VITE_APP_URL": "https://app.yourdomain.com" // the new web custom domain } ``` **② `apps/server/wrangler.jsonc`** ```jsonc "vars": { "WEBSITE_URL": "https://app.yourdomain.com", // sync to the new web domain "SERVER_URL": "https://api.yourdomain.com" } ``` ### Redeploy to apply the changes Both apps have changes, so deploy both: ```bash pnpm deploy ``` This runs `pnpm deploy:server` followed by `pnpm deploy:web`. > `vars` are static config compiled into the bundle. Every change to `wrangler.jsonc` `vars` requires a redeploy — updating secrets alone will not apply these changes. # Web Getting Started (http://page.easystarter.dev/docs/web/getting-started) ## Shared Setup Before starting either client, finish the common workspace setup first: ### Install Prerequisites Ensure your development environment has the necessary tools installed: - Install [`Node.js 20+`](https://nodejs.org/) - Install [`pnpm 9+`](https://pnpm.io/) - Install [`git`](https://git-scm.com/) ### Clone Repository Clone the repository and enter the project root to begin development: ```bash # clone repository git clone https://github.com/sunshineLixun/easystarter.git your-project-name # enter project root cd your-project-name # remove default origin git remote remove origin # add your own origin git remote add origin https://github.com/your-username/your-project-name.git # push to origin git push -u origin main ``` ### Install Dependencies Run the following command to download and install all necessary project dependencies: ```bash pnpm install ``` {props.children} # Introduction (http://page.easystarter.dev/docs/web) import { File, Files, Folder } from "fumadocs-ui/components/files"; ## Welcome to EasyStarter EasyStarter is a modern full-stack template oriented towards SaaS scenarios. It builds the common product infrastructure in advance, including Web apps, Mobile apps, Server APIs, database, authentication, payments, emails, storage, and internationalization, allowing you to focus your main energy on the business itself rather than repeatedly scaffolding. If you want to quickly launch a product with real commercial capabilities, rather than piecing together a tech stack from scratch, EasyStarter's goal is to provide you with a sufficiently clear, complete, and easy-to-evolve starting point. ## What is EasyStarter? EasyStarter organizes code based on a monorepo, expanding by default around the most common capability layers of SaaS products: - **Web App**: Based on [React 19](https://react.dev/), [TanStack Start](https://tanstack.com/start/latest), and [shadcn/ui](https://ui.shadcn.com/), responsible for public pages, authentication flows, and admin interfaces - **Server API**: Based on [Hono](https://hono.dev/) running on [Cloudflare Workers](https://workers.cloudflare.com/), handling authentication, payments, storage, and business interfaces - **Mobile App**: Based on [React Native](https://reactnative.dev/) and [Expo](https://expo.dev/), reusing business capabilities and handling mobile scenarios - **Shared Packages**: Consolidating configurations, types, internationalization, and common utilities into `packages/*` to reduce cross-platform repetition This is not a template containing only UI, nor a boilerplate only suitable for demonstrations. It is closer to a foundational SaaS engineering structure that can continue to scale. ## Project Structure ## Choose a Client - [Web docs](/docs/web): browser pages, dashboard UI, docs site, and web checkout - [Mobile docs](/docs/mobile): Expo app, deep links, mobile auth, and app-store release ## Popular Guides - [Web Getting Started](/docs/web/getting-started) - [Web Project Structure](/docs/web/project-structure) - [Web Authentication](/docs/web/integrations/authentication) - [Mobile Getting Started](/docs/mobile/getting-started) - [Mobile Authentication](/docs/mobile/integrations/authentication) - [Deployment](/docs/web/deploy-web) ## Core Capabilities ### Marketing site - Responsive landing pages built with [shadcn/ui](https://ui.shadcn.com/) and [Tailwind CSS](https://tailwindcss.com/) - Pricing and subscription page previews - MDX documentation site and multi-language support based on [Fumadocs](https://fumadocs.vercel.app/) - Seamless dark/light theme switching ### Authentication - Cross-platform identity authentication system driven by [Better Auth](https://better-auth.com/) - Default support for Email/Password and OAuth login - Built-in Apple native login support on mobile ### Payments & billing - **Web**: [Stripe](https://stripe.com/) checkout session and webhook flow - **Mobile**: [RevenueCat](https://www.revenuecat.com/) for in-app purchases and entitlement flow - Shared pricing catalog through `app-config` ### Database & ORM - [Drizzle ORM](https://orm.drizzle.team/) with strong type inference - [Cloudflare D1](https://developers.cloudflare.com/d1/) for the server-side database layer ### API layer - [Hono](https://hono.dev/) for the server layer - [oRPC](https://orpc.dev/) plus [Zod](https://zod.dev/) for end-to-end typed contracts - Shared API contracts consumed by both Web and Mobile ### Internationalization - Shared i18n architecture across Web, Server, and Mobile - Workspace-level locale organization through `@repo/i18n` ## What projects is it suitable for? EasyStarter is suitable for these scenarios: - You want to quickly launch a SaaS MVP without rebuilding auth, payments, and email from scratch - You want Web, Server, and Mobile to share the same core business configuration and types - You need a manageable engineering foundation that can continue to scale ## FAQ ### Is EasyStarter just a frontend template? No. EasyStarter is a complete full-stack template that includes Web frontend, Server API, Mobile app, database access, authentication, payment, and email capability. ### Is EasyStarter ready for production? Its positioning is a production-ready foundation. The infrastructure is in place, but you still need to add the business-specific models, permissions, pages, and workflows. ## Next Steps If this is your first time encountering the project, read in this order: 1. [Web Getting Started](/docs/web/getting-started) or [Mobile Getting Started](/docs/mobile/getting-started) 2. [Project Structure](/docs/web/project-structure) 3. [Cloudflare Integration](/docs/web/integrations/cloudflare) and [Database](/docs/web/integrations/database) # Analytics (http://page.easystarter.dev/docs/web/integrations/analytics) ## Analytics EasyStarter Web ships with two optional analytics providers. Both are skipped automatically when their environment variables are empty: | Provider | Purpose | | --- | --- | | [Google Analytics 4](https://analytics.google.com/) | Traffic, conversion funnels, audience reports | | [OpenPanel](https://openpanel.dev/) | Open-source, privacy-friendly product analytics (events, retention, funnels) | You can enable either, both, or neither — leave the variable blank to disable. Track only the key funnel events by default: signup completed, login succeeded, subscription started, core generation finished, submission created, and similar conversion points. The template keeps GA4 page views enabled, but OpenPanel does not automatically track every route change so free-tier usage is not spent on low-signal navigation events. ## Google Analytics 4 ### Create a GA4 property and grab the Measurement ID Official docs: [Find your Google tag / Measurement ID](https://support.google.com/analytics/answer/12270356) 1. Visit [analytics.google.com](https://analytics.google.com/) and sign in 2. Open **Admin → Create → Property**, fill in product name, time zone, currency 3. Inside the new property, pick **Data Streams → Add stream → Web** 4. Enter the site URL (e.g. `https://yourdomain.com`) and create the stream 5. Copy the **Measurement ID** shown on the stream detail page — format `G-XXXXXXXXXX` ### Set environment variables **Local development** (`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX ``` **Local production deploy** (`vars` block of `apps/web/wrangler.jsonc`, plain text — not a Secret): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_GA_MEASUREMENT_ID": "G-XXXXXXXXXX", // ... } } ``` **CI / remote-deploy variable list** (`apps/web/.env.production`): Used by local production builds (`pnpm build:web:production`) and as the canonical list of variables to mirror into Cloudflare's build environment when deploying from GitHub: ```bash title="apps/web/.env.production" VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX ``` > `VITE_`-prefixed values end up in the client bundle and aren't sensitive — keep them in `wrangler.jsonc` / CI build variables, no `pnpm run secrets:bulk:production` needed. `.env.production` is gitignored and stays local. ### Track key events explicitly The Web app exposes small tracking helpers. Call them only from conversion points that matter to the product: ```ts import { trackGoogleEvent } from "@/lib/analytics/google-analytics"; import { trackOpenPanelEvent } from "@/lib/analytics/openpanel"; trackGoogleEvent("sign_up", { method: "google", }); trackOpenPanelEvent("subscription_started", { plan: "pro", }); ``` If a route is truly part of the product funnel, record an OpenPanel screen view manually: ```ts import { trackOpenPanelScreenView } from "@/lib/analytics/openpanel"; trackOpenPanelScreenView("/pricing"); ``` Do not treat every route transition as a product event. Start with 3-5 key funnel steps, then add more events when the data answers a real product question. ## OpenPanel [OpenPanel](https://openpanel.dev/) is an open-source product analytics platform. You can self-host it or use the Cloud version — the free tier is plenty for an indie project. EasyStarter targets the Cloud version by default. ### Create an OpenPanel project Official docs: [Web SDK](https://openpanel.dev/docs/sdks/web) 1. Sign up at [openpanel.dev](https://openpanel.dev/) 2. In the dashboard click **Create Project** 3. Fill in **Project name** 4. Keep **Website** enabled, and turn off **App** and **Backend / API** unless you need them now 5. Enter the production site URL in **Domain**, e.g. `https://yourdomain.com` 6. Add event-ingestion origins under **Allowed domains**, e.g. `https://yourdomain.com` 7. Click **Create project** 8. After creation, copy the Website **Client ID** from the project's client details (UUID) ### Set environment variables **Local development** (`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` **Local production deploy** (`apps/web/wrangler.jsonc`): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_OPENPANEL_CLIENT_ID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // ... } } ``` ```bash title="apps/web/.env.production" VITE_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` > Also a public value. For CI deploys, add it to the platform's **build variables**, not the secrets section. # Alibaba Cloud Phone Sign-In (Recommended for China) (http://page.easystarter.dev/docs/web/integrations/authentication/aliyun-phone-auth) ## Alibaba Cloud Phone Sign-In EasyStarter ships with phone sign-in powered by the [Better Auth phone-number plugin](https://www.better-auth.com/docs/plugins/phone-number). The server sends and verifies SMS codes through Alibaba Cloud Dypnsapi, while the clients keep using Better Auth's `phoneNumber.sendOtp` and `phoneNumber.verify` APIs. If your product is deployed primarily in mainland China, phone sign-in should be the preferred, and often the only, sign-in method. SMS-code sign-in is the most familiar flow for local users, while GitHub, Google, Apple, and similar OAuth providers add extra availability, account-coverage, and compliance friction in China. Email/password can stay if your product needs it, but it is not required. For a China-first deployment, configuring Alibaba Cloud phone sign-in alone is enough; the other sign-in methods can remain disabled and unconfigured. The built-in flow currently supports mainland China phone numbers only: | Item | Current setup | | --- | --- | | Phone format | `+86` E.164 format, for example `+8613800138000` | | Send API | `SendSmsVerifyCode` | | Verify API | `CheckSmsVerifyCode` | | Server provider | `apps/server/src/sms/providers/aliyun.ts` | | Better Auth config | `apps/server/src/lib/auth.ts` | ## Required Environment Variables ```bash ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= ``` These credentials are used by the server to call Alibaba Cloud OpenAPI. Do not commit them and do not expose them to frontend environment variables. If you only keep phone sign-in, OAuth variables such as `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, and `GOOGLE_CLIENT_SECRET` do not need to be configured. In production, keep the base Better Auth session variables plus the Alibaba Cloud AccessKey variables in this guide. ### Enable Phone Number Verification Service First, make sure your Alibaba Cloud account has enabled Phone Number Verification Service and can call the Dypnsapi SMS verification APIs. Official API doc: [SendSmsVerifyCode](https://api.aliyun.com/document/Dypnsapi/2017-05-25/SendSmsVerifyCode) Alibaba Cloud documents `SendSmsVerifyCode` as the SMS verification-code sending API under Dypnsapi. It uses API version `2017-05-25`, and the permission action is `dypns:SendSmsVerifyCode`. ### Create a RAM User Use a RAM user AccessKey instead of an Alibaba Cloud root account AccessKey. 1. Log in to the [Alibaba Cloud RAM Console](https://ram.console.aliyun.com/) 2. Go to **Identities** → **Users** 3. Click **Create User** 4. Fill in the required information 5. In access configuration, select **Use permanent AccessKey to access** 6. After the user is created, the console returns to the user list automatically ### Get AccessKey ID and AccessKey Secret 1. In the user list, find the RAM user you just created. The AccessKey column shows the AccessKey ID and AccessKey Secret; click to copy them. `AccessKey ID` and `AccessKey Secret` are only shown once when they are created. If you lose them, disable the old key and create a new AccessKey. ### Grant the RAM User Permission to Call Dypnsapi 1. In the user list, find the RAM user you just created, then click the `Logon Name / Display Name` to open the user detail page 2. Click **Permissions** → **Grant Permission** 3. In the **Policy** step, search for `dypns`, find **AliyunDypnsReadOnlyAccess** and **AliyunDypnsFullAccess**, then select them You can also choose **PowerUserAccess**. It provides full access to Alibaba Cloud services and resources, including SMS, OSS, and other services. For least privilege, grant only **AliyunDypnsReadOnlyAccess** and **AliyunDypnsFullAccess**. They include all permissions for Phone Number Verification Service without granting access to unrelated services. 4. Confirm the authorization ### Set Local and Production Environment Variables For local development, add them to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` For production, add them to `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` `.env.production` is only used to bulk-push Cloudflare Workers Secrets. It does not participate in frontend builds and should not be committed. ### Push Production Secrets Before deploying to Cloudflare Workers, push the production credentials to Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` After this succeeds, the Worker runtime can read `env.ALIBABA_CLOUD_ACCESS_KEY_ID` and `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`. Re-run the same command whenever you rotate the credentials. ### Keep the Alibaba Cloud Verification Parameters Unchanged The built-in provider locks these parameters according to Alibaba Cloud's `SendSmsVerifyCode` documentation: ```ts title="apps/server/src/sms/providers/aliyun.ts" const ALIYUN_SMS_VERSION = "2017-05-25"; const ALIYUN_SMS_SIGN_NAME = "速通互联验证码"; const ALIYUN_SMS_TEMPLATE_CODE = "100001"; ``` Do not change these values: | Constant | Alibaba Cloud parameter | Why it must stay unchanged | | --- | --- | --- | | `ALIYUN_SMS_VERSION` | OpenAPI version | Dypnsapi's `SendSmsVerifyCode` API version is `2017-05-25` | | `ALIYUN_SMS_SIGN_NAME` | `SignName` | The API documentation uses the bundled Phone Number Verification sign name `速通互联验证码`; this API does not support ordinary custom SMS signs | | `ALIYUN_SMS_TEMPLATE_CODE` | `TemplateCode` | The bundled sign must be used with the bundled template, whose code is `100001` | This integration uses Dypnsapi's SMS verification API, not the ordinary Dysmsapi `SendSms` API. Do not replace the template code with an `SMS_...` code from the ordinary SMS service. ### Verify Phone Sign-In Locally Start the server and web app, then choose phone sign-in on the login page: ```bash pnpm dev:server pnpm dev:web ``` When the user requests a code, the frontend calls: ```bash POST /api/auth/phone-number/send-otp ``` When the user submits the code, it calls: ```bash POST /api/auth/phone-number/verify ``` The server converts the `+86` E.164 number into `CountryCode=86` and the local phone number required by Alibaba Cloud. Alibaba Cloud then generates, sends, and verifies the code. ## Common Questions ### Why not generate the code ourselves? The current implementation uses `TemplateParam={"code":"##code##","min":"5"}`, so Alibaba Cloud generates the code. This lets the server verify the submitted code through `CheckSmsVerifyCode` without storing OTP state itself. ### Why can't I use my own SMS sign? `SendSmsVerifyCode` belongs to Phone Number Verification Service. Alibaba Cloud's documentation says the bundled sign must be used with the bundled template, and ordinary custom signs are not supported by this API. The built-in values match the official example. ### What should I do if the AccessKey leaks? Disable or delete the leaked AccessKey in the RAM Console immediately, create a new one, and push the updated `apps/server/.env.production` to Workers Secrets again. # Email OTP Login (http://page.easystarter.dev/docs/web/integrations/authentication/email-otp) ## Email OTP Login EasyStarter includes a built-in email OTP (one-time password) login powered by the [Better Auth Email OTP plugin](https://www.better-auth.com/docs/plugins/email-otp). Users simply enter their email address and receive a one-time verification code to sign in — no password required. ### How It Works 1. The user enters their email address on the sign-in page 2. The server sends a one-time verification code to that email via the [Email Service](/docs/web/integrations/email) 3. The user enters the received code 4. The server verifies the code and completes sign-in (auto-registers if the user doesn't exist) ### Enabling Email OTP Login Email OTP login is controlled by a feature flag in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" auth: { methods: { emailOtpEnabled: true, }, } ``` ### Prerequisites Email OTP login depends on the email sending capability. Make sure you have completed the [Email Service](/docs/web/integrations/email) setup first. ### OTP Configuration The verification code behavior is configured in the `auth.otp.email` section of `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" auth: { otp: { email: { // Number of digits in the verification code otpLength: 6, // Code expiration time in seconds expiresInSeconds: 300, // Maximum verification attempts per issued code allowedAttempts: 3, // Client-side resend cooldown in seconds resendCooldownSeconds: 60, }, }, } ``` ### Rate Limiting The server applies dedicated rate limits to email OTP endpoints to prevent abuse: ```ts title="apps/server/src/lib/auth.ts" rateLimit: { customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 }, "/sign-in/email-otp": { window: 60, max: 10 }, }, } ``` - Send verification code: max 3 requests per 60 seconds - Verify and sign in: max 10 requests per 60 seconds # Email Password Login (http://page.easystarter.dev/docs/web/integrations/authentication) ## Email Password Login EasyStarter uses [Better Auth](https://www.better-auth.com/) as its authentication solution, with built-in email and password sign-in. The server-side configuration lives in `apps/server/src/lib/auth.ts`. If you enable email verification or forgot password, complete the [Email Service](/docs/web/integrations/email) setup first. ## Required Environment Variables ```bash BETTER_AUTH_SECRET= ``` ### Get `BETTER_AUTH_SECRET` `BETTER_AUTH_SECRET` is used by Better Auth to sign and encrypt session data. It should be a sufficiently long random string. You can generate one yourself, for example: ```bash openssl rand -base64 32 ``` Copy the generated value into: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ### Set the environment variables For local development, it is simplest to put everything into `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` For production, keep the sensitive values in `apps/server/.env.production`: ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ## Email Password Authentication Features In EasyStarter, email password authentication currently handles: - Email/password sign-up and sign-in - Email verification - Forgot password - Cookie-based session management Core config file: ```bash apps/server/src/lib/auth.ts ``` # Social Login (http://page.easystarter.dev/docs/web/integrations/authentication/social-login) ## Social Login EasyStarter ships with the following social login providers: - GitHub OAuth sign-in - Google OAuth sign-in The server-side configuration lives in `apps/server/src/lib/auth.ts`. In that file: - GitHub callback URL: `{SERVER_URL}/api/auth/callback/github` - Google callback URL: `{SERVER_URL}/api/auth/callback/google` ## Required Environment Variables ```bash GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= ``` ### Create a GitHub OAuth App GitHub OAuth is used for GitHub sign-in on web and native clients. GitHub developer console: [GitHub Developer Settings](https://github.com/settings/developers) 1. Sign in to GitHub and open `Settings` 2. Go to `Developer settings` 3. Open `OAuth Apps` 4. Click `New OAuth App` 5. Fill in the application details These fields should typically be set like this: - `Application name`: your product name - `Homepage URL`: your website URL, for example `https://yourdomain.com` - `Authorization callback URL`: `{SERVER_URL}/api/auth/callback/github` For example, if your server URL is: ```bash SERVER_URL=https://server.yourdomain.com ``` Then the callback URL should be: ```bash https://server.yourdomain.com/api/auth/callback/github ``` In local development, `easystarter` uses `http://localhost:3001` for the server by default, so this is usually: ```bash http://localhost:3001/api/auth/callback/github ``` After creation, GitHub gives you: - `Client ID` -> maps to `GITHUB_CLIENT_ID` - `Client Secret` -> maps to `GITHUB_CLIENT_SECRET` ### Create a Google OAuth Client Google OAuth is used for Google sign-in on web and native clients. Google Cloud Console: [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 1. Sign in to Google Cloud Console 2. Select or create a project 3. Go to `APIs & Services > Credentials` 4. Click `Create Credentials` 5. Choose `OAuth client ID` 6. If prompted, complete the `OAuth consent screen` first 7. Set the application type to `Web application` 8. Configure the allowed origins and callback URL These fields should typically be set like this: - `Authorized JavaScript origins`: your website URL, for example `https://yourdomain.com` - `Authorized redirect URIs`: `https://yourdomain.com/api/auth/callback/google` For example, if your server URL is: ```bash SERVER_URL=https://server.yourdomain.com ``` Then these should be: - `Authorized JavaScript origins`: `https://server.yourdomain.com` - `Authorized redirect URIs`: `https://server.yourdomain.com/api/auth/callback/google` In local development, `easystarter` uses `http://localhost:3001` for the server by default, so this is usually: - `Authorized JavaScript origins`: `http://localhost:3001` - `Authorized redirect URIs`: `http://localhost:3001/api/auth/callback/google` After creation, Google gives you: - `Client ID` -> maps to `GOOGLE_CLIENT_ID` - `Client Secret` -> maps to `GOOGLE_CLIENT_SECRET` ### Set the environment variables For local development, it is simplest to put everything into `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` For production, keep the sensitive values in `apps/server/.env.production`: ```bash title="apps/server/.env.production" GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret ``` Then add the non-sensitive `GITHUB_CLIENT_ID` and `GOOGLE_CLIENT_ID` to the `vars` section in `apps/server/wrangler.jsonc`: ```json title="apps/server/wrangler.jsonc" "vars": { "GITHUB_CLIENT_ID": "your-github-client-id", "GOOGLE_CLIENT_ID": "your-google-client-id" } ``` ## Adding More Providers If you later want to add more providers such as Apple, Discord, or GitLab, extend the `socialProviders` configuration in `apps/server/src/lib/auth.ts`. # Cloudflare (http://page.easystarter.dev/docs/web/integrations/cloudflare) ## Cloudflare Integration EasyStarter runs its server layer on Cloudflare infrastructure, mainly using: - Cloudflare Workers - Cloudflare D1 - Cloudflare R2 If you want to run database migrations, deploy the server, or configure object storage, you will usually need the Cloudflare credentials below first. ## Required Environment Variables ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` - `CLOUDFLARE_ACCOUNT_ID`: your Cloudflare account ID - `CLOUDFLARE_API_TOKEN`: the API token used to call the Cloudflare API These values are typically used by `apps/server/drizzle.config.ts` so `drizzle-kit` can run database commands over the D1 HTTP driver. ## Get `CLOUDFLARE_ACCOUNT_ID` Official doc: [Find account and zone IDs](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/) ### Option 1: From Account Home 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Open `Account Home`. 3. Find your account row. 4. Click the menu button on the right. 5. Select `Copy account ID`. That copied value is your `CLOUDFLARE_ACCOUNT_ID`. ### Option 2: From Workers & Pages 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Open `Workers & Pages`. 3. Find `Account ID` in the `Account details` section. 4. Copy the value. ## Get `CLOUDFLARE_API_TOKEN` Official doc: [Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) Use an API Token here, not the legacy Global API Key. ### Create the token 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/). 2. Go to `My Profile > API Tokens`. 3. Click `Create Token`. 4. Choose `Custom token`. 5. Give it a clear name, such as `easystarter-d1-migrate`. 6. Add these permissions: - `Account` -> `D1` -> `Edit` - `Account` -> `Workers R2 Storage` -> `Edit` - `Account` -> `Workers Scripts` -> `Edit` 7. Scope the resources to the account used by this project. 8. Click `Continue to summary`. 9. Review the permissions and resource scope. 10. Click `Create Token`. 11. Copy the generated token secret. That copied value is your `CLOUDFLARE_API_TOKEN`. ### Notes - the token secret is shown only once - if you lose it, create a new token - store it only in `.dev.vars`, `.env.production`, or CI secrets ## Where To Put Them Place these variables in the `apps/server` directory as either `.dev.vars` or `.env.production`. ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` ```bash title="apps/server/.env.production" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` # Credits (http://page.easystarter.dev/docs/web/integrations/credits) ## Credits EasyStarter ships a credit system backed by a server-side ledger. On the web app, users buy credit packages through your web payment provider — Stripe or Creem: - **Sell credit packages** through Stripe / Creem checkout - **Grant free credits on signup**, with optional expiration - **Abuse protection**: signup grants require a verified email and are rate-limited per email, IP, and user agent - **Consume credits per feature** with idempotent, race-safe accounting - **Ready-made UI**: balance indicator, purchase page, transaction history The ledger is the single source of truth. The client never changes a balance directly — only the payment webhook (on purchase) and server-side consumption write to it. > Building the mobile app too? Credits share one server and config — see [Mobile · Credits](/docs/mobile/integrations/credits) for the app setup. ## Architecture | Layer | Location | | --- | --- | | Config | `packages/app-config/src/app-config.ts` | | Server service | `apps/server/src/credits/*` | | API routes | `apps/server/src/routers/common/credits.ts`, `apps/server/src/routers/web/credits.ts` | | Web UI | `apps/web/src/routes/_authed/(dashboard)/credits/*`, `apps/web/src/hooks/use-credits.ts` | The credit tables: | Table | Purpose | | --- | --- | | `credit_account` | Current balance and aggregate counters per user | | `credit_transaction` | Immutable ledger rows; the `(sourceProvider, sourceType, sourceId)` tuple is the idempotency key | | `credit_order` | Web purchase order lifecycle | | `credit_signup_grant_claim` | Signup grant eligibility and abuse checks | ## 1. Configure credit packages Everything is configured in `packages/app-config/src/app-config.ts`. ### Enable web credits ```ts title="packages/app-config/src/app-config.ts" web: { credits: { enabled: true, signupGrant: creditSignupGrant, packages: webCreditPackages, }, }, ``` ### Configure the signup grant Grants free credits to a new user on first balance read. Set `expiresInDays: null` (or omit) for credits that never expire. ```ts title="packages/app-config/src/app-config.ts" const creditSignupGrant = { enabled: true, amount: 100, // credits granted on signup expiresInDays: 30, // null = never expires } satisfies NonNullable; ``` ### Create the one-time product Credit packages are **one-time payments** — never a subscription. Create the product in your provider's dashboard, then copy its price / product ID for the next step. **Stripe** 1. Dashboard → **Products → Add product**, name it (e.g. `100 Credits`). 2. Under **Pricing**, choose **One time** (not Recurring), set the amount and currency, then save. 3. Open the price and copy its **Price ID** (`price_xxx`). 4. Toggle **Test mode** on/off to create a price in each environment — you need one ID for `test` and one for `prod`. **Creem** 1. Dashboard → **Products → Create product**, name it (e.g. `100 Credits`). 2. Set the billing type to **One time** and set the amount. 3. Save, then copy the **Product ID** (`prod_xxx`) — Creem uses the Product ID as the price id. 4. Repeat in both the test and live environments. > Full provider setup (API keys, webhooks) lives in the [Stripe](/docs/web/integrations/payments/stripe) and [Creem](/docs/web/integrations/payments/creem) guides. ### Configure web packages Put the price / product IDs from the previous step into `web` for each package. The active environment is picked from `NODE_ENV`. ```ts title="packages/app-config/src/app-config.ts" const webCreditPackages = [ { id: "starter", // internal package id amount: 100, // credits delivered after purchase web: { provider: "stripe", // "stripe" | "creem" test: { providerPriceId: "price_xxx" }, prod: { providerPriceId: "price_xxx" }, currency: "usd", amountCents: 499, // $4.99 status: "active", }, }, ] satisfies AppCreditsConfig["packages"]; ``` ### Add package labels Add a title and description for each package `id` so the purchase UI can render it. ```jsonc title="packages/i18n/src/messages/web/en.json" "credits": { "packages": { "starter": { "title": "Starter pack", "description": "{count} credits for light usage." } } } ``` **Configuration rules** - `amount` and `amountCents` must be positive integers. - Both `test` and `prod` provider price IDs are required and must be unique. - Set `status: "archived"` to hide a package without deleting history. - If the same package is also sold in the app, reuse the same `id` (with matching `amount` and `status`) — see [Mobile · Credits](/docs/mobile/integrations/credits). ## 2. Set up the server ### Run migrations ```bash pnpm db:migrate:local # local D1 pnpm db:migrate # remote D1 ``` ### Configure payment secrets Credits reuse your web payment provider, so no extra secrets are needed beyond what [Stripe](/docs/web/integrations/payments/stripe) / [Creem](/docs/web/integrations/payments/creem) already require: | Provider | Secrets | | --- | --- | | Stripe | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | | Creem | `CREEM_API_KEY`, `CREEM_WEBHOOK_SECRET` | ### Confirm the maintenance cron `apps/server/src/index.ts` runs `runCreditMaintenance` on a daily schedule defined in `apps/server/wrangler.jsonc`. It expires free credits past their window and cancels stale pending orders. ```jsonc title="apps/server/wrangler.jsonc" "triggers": { "crons": ["10 16 * * *"] } ``` ## 3. Consume credits Spending credits is the part you wire into your own features. Prefer the **server-side** service from inside a route, so balance checks can't be bypassed by the client. ```ts title="server route" await context.credits.consumeCredits({ user: { userId: context.session.user.id }, amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` From the browser, call the API directly: ```ts await orpc.credits.consume.call({ amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` > `idempotencyKey` must identify one real usage event (8–120 chars). Retrying with the same key returns the current balance without charging twice. Consumption spends soonest-to-expire credits first; an insufficient balance throws `Insufficient credits`. ## Ledger rules - Signup credits are granted lazily on first balance / transaction read or consume. They require a verified email and are rate-limited per email, IP, and user agent. - Purchased credits never expire (`expiresAt = null`). - Free grants expire after `expiresInDays`; the daily cron sweeps them. - Consumption spends soonest-to-expire credits first, then permanent paid credits. - Refunds revoke only the unspent remainder of the original purchase. # Database (http://page.easystarter.dev/docs/web/integrations/database) ## Database The project uses [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) as its database layer. ### Create the D1 database See official docs: [D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) Option 1: Cloudflare Dashboard 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **Storage & databases → D1 SQL database** 3. Click **Create database** 4. Enter a database name, for example `easysaas-db` 5. Choose a location if needed 6. Click **Create** Once created, copy the `database_id` from the database details page. Option 2: Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` On success, Wrangler outputs a D1 binding snippet that contains the `database_id`. ### Configure the D1 database ID After obtaining your `database_id`, add it to the following two locations. Environment variables: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` For how to obtain `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, see [Cloudflare Integration](/docs/web/integrations/cloudflare). Set `database_id` in: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` and: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler config: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` That means `apps/server/wrangler.jsonc` must use the same `database_id`. ### Run the local database workflow For local database development, use these three commands in this order: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` Generate migration files from `apps/server/src/db/schema`. `pnpm db:migrate:local` Apply the generated migrations to the local D1 database. This command already handles local D1 initialization. `pnpm db:studio:local` Open the local D1 visual UI so you can inspect tables and data. # Cloudflare Email Service (http://page.easystarter.dev/docs/web/integrations/email) ## Cloudflare Email Service Cloudflare Email Service is a convenient option when your domain is already managed by Cloudflare. Once configured, users can receive sign-up verification, password reset, and email verification code messages in their own inboxes. Cloudflare Email Service does not require a separate email API key or any additional email environment variables. ## Enable email sending in production ### Enable Email Sending 1. Sign in to the [Cloudflare dashboard](https://dash.cloudflare.com/) 2. Go to **Compute → Email Service → Email Sending** 3. Click **Onboard Domain** and select your sender domain 4. Complete the DNS setup shown on the page 5. Wait until the domain is enabled When the domain is already managed by Cloudflare, the required records can usually be configured directly from the dashboard. Sending to arbitrary real user addresses requires the [Workers Paid plan](https://developers.cloudflare.com/email-service/platform/pricing/). ### Select Cloudflare as the email provider In `packages/app-config/src/app-config.ts`, switch the email provider to `cloudflare` and enter the domain you just verified: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "cloudflare", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### Deploy and receive a test email Deploy the server normally. A deployed Worker connects directly to the real Cloudflare email service. `remote: true` is only for local testing and is not required in production. After deployment, use a real address to trigger sign-up verification, forgot password, or an email verification code. Receiving the message confirms that production sending is active. If it does not appear immediately, check the spam folder and the activity log on the Cloudflare Email Sending page. ## Receive real email during local development By default, Cloudflare simulates email sending during local development. The message is shown in the terminal and saved as a local preview, but nothing is delivered to a real inbox. To receive the message in your own test inbox, temporarily enable remote sending in `apps/server/wrangler.jsonc`: ```jsonc title="apps/server/wrangler.jsonc" "send_email": [ { "name": "EMAIL", "remote": true, }, ], ``` Make sure Wrangler is signed in to the Cloudflare account that owns the onboarded domain, restart the local server, and trigger sign-up verification, forgot password, or an email verification code. The message will be delivered to the test address you entered. Check the spam folder if it is not visible in the inbox. Remove `remote: true` after testing to avoid sending real email accidentally during everyday development. Cloudflare activity logs may appear later than the email itself, so use the received test message as the primary confirmation. # Resend Email Service (http://page.easystarter.dev/docs/web/integrations/email/resend) ## Resend Email Service Resend sends email using an API key. Once configured, users can receive sign-up verification, password reset, and email verification code messages in their own inboxes. ## Enable email sending in production ### Create a Resend API Key 1. Create an account at [resend.com](https://resend.com/) 2. Open the [API Keys](https://resend.com/api-keys) page 3. Click **Create API Key** 4. Select **Sending access** 5. Copy the API Key immediately after creating it The key starts with `re_` and is only displayed once, so store it safely. ### Verify your sender domain 1. Open **Domains** in Resend 2. Click **Add Domain** and enter your sender domain 3. Add the DNS records shown by Resend to your DNS provider 4. Return to Resend and click **Verify DNS Records** 5. Wait until the domain is verified Once verified, you can send from an address such as `noreply@yourdomain.com`. ### Add the production configuration Add `RESEND_API_KEY` to `apps/server/.env.production`: ```bash title="apps/server/.env.production" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Then select Resend in `packages/app-config/src/app-config.ts` and enter the verified domain: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "resend", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### Deploy and receive a test email Push the production secrets and deploy the server normally. After deployment, use a real address to trigger sign-up verification, forgot password, or an email verification code. Receiving the message confirms that production sending is active. If it does not appear immediately, check the spam folder and Resend Logs. ## Receive real email during local development Add the same `RESEND_API_KEY` to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Make sure the email provider is set to `resend`, restart the local server, and trigger sign-up verification, forgot password, or an email verification code. The message will be delivered directly to the test address you entered. During initial testing, Resend allows you to send to the email address registered with your Resend account. Verify your sender domain before sending to other users. If the message does not arrive, check the spam folder and Resend Logs. # Live Chat (http://page.easystarter.dev/docs/web/integrations/live-chat) ## Live Chat EasyStarter uses [tawk.to](https://www.tawk.to/) as its live chat integration. ## Get the IDs ### Copy Property ID and Widget ID from Tawk.to 1. Create a free account at [tawk.to](https://www.tawk.to/) and sign in 2. Select the matching property; create one if it does not exist yet ![Switch property from the top-left of the tawk.to dashboard](/images/docs/tawkto/select-property.png) 3. Click **Administration** in the left sidebar ![Administration gear in the tawk.to left sidebar](/images/docs/tawkto/administration.png) 4. In **Overview**, the **Property ID** is below the Property Image ![Property ID field on the Overview page](/images/docs/tawkto/property-id.png) 5. Open **Channels → Chat Widget** ![Chat Widget item in the Administration submenu](/images/docs/tawkto/chat-widget-menu.png) 6. The **Widget ID** is under **Widget Status** ![Widget ID field under Widget Status on the Chat Widget page](/images/docs/tawkto/widget-id.png) You can also copy both IDs from the **Direct Chat Link** after `https://tawk.to/chat/`, or from the **Widget Code** script URL: `https://embed.tawk.to/{PROPERTY_ID}/{WIDGET_ID}` ![Property ID and Widget ID in the Direct Chat Link](/images/docs/tawkto/direct-chat-link.png) ### Set environment variables Fill both values. If either is empty, the widget stays disabled. These are public `VITE_` variables baked into the client bundle. Do not put them in `apps/server/.dev.vars`. **Local development** (`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_TAWK_PROPERTY_ID=xxxxxxx VITE_TAWK_WIDGET_ID=xxxxxxx ``` **Local production deploy** (`vars` block of `apps/web/wrangler.jsonc`, plain text — not a Secret): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_TAWK_PROPERTY_ID": "xxxxxxx", "VITE_TAWK_WIDGET_ID": "xxxxxxx", // ... } } ``` **CI / remote-deploy variable list** (`apps/web/.env.production`): ```bash title="apps/web/.env.production" VITE_TAWK_PROPERTY_ID=xxxxxxx VITE_TAWK_WIDGET_ID=xxxxxxx ``` # Creem Payments (http://page.easystarter.dev/docs/web/integrations/payments/creem) ## Creem Payment Integration EasyStarter's web app ships with a fully integrated [Creem](https://creem.io/) payment system for **web-only** scenarios, including: - Subscription checkout (monthly / yearly) - One-time lifetime purchase checkout - Free trial support - Customer Billing Portal (self-serve subscription management) - In-app subscription upgrades (with proration) - Webhook event handling (subscription sync, refunds, disputes, and more) Payment setup is split into two parts: 1. **Environment variables**: API key and webhook secret, added to `.dev.vars` / `.env.production` 2. **Pricing plans**: Creem Product IDs and pricing metadata configured in `packages/app-config/src/app-config.ts` ## Required Environment Variables ```bash CREEM_API_KEY= CREEM_WEBHOOK_SECRET= ``` ### Register on Creem and get your API key 1. Go to [creem.io](https://creem.io/) and create an account 2. Log in and navigate to **API Keys** in your dashboard 3. Copy the **API key** (test keys start with `creem_test_`, live keys with `creem_live_`) > Start with the test key during development. Switch to the live key before going to production. Fill in the copied key: ```bash title="apps/server/.dev.vars" CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" CREEM_API_KEY=creem_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` EasyStarter automatically routes API requests based on the key prefix: - `creem_test_` → `https://test-api.creem.io/v1` - `creem_live_` → `https://api.creem.io/v1` ### Create products in Creem EasyStarter defaults to three plans: **Free**, **Pro** (monthly + yearly), and **Lifetime** (one-time purchase). The **Free** plan requires no Creem product. Follow these steps to create Pro and Lifetime: 1. Go to **Products** in your Creem dashboard and click **Create product** 2. Enter a product name, e.g. `Pro Monthly` 3. **Subscription plans (monthly / yearly)**: set the billing type to **Recurring** — create one product for monthly and one for yearly, each with its own billing cycle 4. **Lifetime plan**: set the billing type to **One time** — create one product 5. After saving each product, copy its **Product ID** (format: `prod_xxxxxxx`) — you'll use these in the next step ### Configure pricing plans Add the Product IDs from the previous step to the `web.payments.plans` field in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "creem", // test/prod hold environment-specific Creem Product IDs. // Use test product IDs with creem_test_, and live product IDs with creem_live_. plans: [ { id: "free", // Free plan — no Product ID required }, { id: "pro", prices: [ { id: "monthly", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // sandbox test Creem monthly Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // production Creem monthly Product ID }, currency: "usd", amountCents: 1000, // $10.00 priceType: "subscription", interval: "month", trialDays: 7, // Must match the trial days set on the Creem product status: "active", }, { id: "yearly", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // sandbox test Creem yearly Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // production Creem yearly Product ID }, currency: "usd", amountCents: 10000, // $100.00 priceType: "subscription", interval: "year", trialDays: 7, // Must match the trial days set on the Creem product status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // sandbox test Creem one-time Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // production Creem one-time Product ID }, currency: "usd", amountCents: 20000, // $200.00 priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` Field reference: | Field | Description | | --- | --- | | `test.providerPriceId` | Creem Product ID from the test environment, format `prod_xxx` | | `prod.providerPriceId` | Creem Product ID from the production environment, format `prod_xxx` | | `amountCents` | Price in cents — `1000` = $10.00 | | `priceType` | `"subscription"` for recurring, `"lifetime"` for one-time | | `interval` | Billing cycle: `"month"` or `"year"` (omit for lifetime) | | `trialDays` | Must match the trial days configured on the Creem product — Creem does not support setting trial periods via API, so this value is display-only and must stay in sync with the product setting in the Creem dashboard | | `status` | `"active"` to show / `"archived"` to hide from the pricing page | ### Configure Creem Webhooks Webhooks are how Creem notifies your server about events like successful payments, subscription changes, and refunds. They are essential for keeping your database in sync. 1. In your Creem dashboard, go to **Webhooks** 2. Click **Add endpoint** 3. Set the **Endpoint URL**: - Local development: `https://your-ngrok-url/api/webhooks/creem` (use [ngrok](https://ngrok.com/) or similar tunnel) - Production: `https://your-server.workers.dev/api/webhooks/creem` 4. Under **Events to send**, select the following events (all handled by EasyStarter): | Event | Description | | --- | --- | | `checkout.completed` | Checkout completed (subscription or one-time) | | `subscription.active` | Subscription activated | | `subscription.trialing` | Trial started | | `subscription.paid` | Recurring payment processed | | `subscription.scheduled_cancel` | Scheduled for cancellation at period end | | `subscription.past_due` | Payment overdue | | `subscription.update` | Subscription modified | | `subscription.expired` | Subscription period ended | | `subscription.canceled` | Subscription canceled | | `subscription.paused` | Subscription paused | | `refund.created` | Refund processed | | `dispute.created` | Chargeback / dispute opened | 5. After saving, copy the **Webhook Secret** and fill it in: ```bash title="apps/server/.dev.vars" CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` > Creem uses HMAC-SHA256 to sign webhook payloads. EasyStarter verifies the signature from the `creem-signature` header automatically. ### Set environment variables and start the server Confirm both variables are set in your dev environment: ```bash title="apps/server/.dev.vars" CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Start the server: ```bash pnpm dev:server ``` > Since Creem does not provide a CLI tool like Stripe CLI, you'll need a tunnel service (e.g. [ngrok](https://ngrok.com/)) to test webhooks locally: > ```bash > ngrok http 3001 > ``` > Then set the ngrok URL as your webhook endpoint in the Creem dashboard. ## Billing route configuration The redirect URLs after a successful or cancelled payment are configured in `web.routes` inside `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { routes: { billingSuccess: "/billing/success", // Redirect after successful payment billingCancel: "/billing/cancel", // Redirect after cancelled payment billingReturn: "/settings/billing", // Redirect after leaving Billing Portal }, }, ``` ## Customer Billing Portal EasyStarter includes Creem Customer Billing Portal integration out of the box. Users can manage their subscriptions from `/settings/billing`, where the app creates a portal session via the Creem API and redirects them to Creem's hosted management UI. ## Subscription status mapping Creem has more granular subscription states than Stripe. Here's how they map to EasyStarter's internal statuses: | Creem State | Internal Status | Has Access | Notes | | --- | --- | --- | --- | | `active` | active | Yes | Normal active subscription | | `trialing` | trialing | Yes | Free trial period | | `scheduled_cancel` | active (cancelAtPeriodEnd) | Yes | Access continues until period end | | `paused` | paused | No | Subscription is paused | | `past_due` | unpaid | No | Payment overdue | | `unpaid` | unpaid | No | Payment failed | | `incomplete` | unpaid | No | Setup incomplete | | `failed` | unpaid | No | Payment failed | | `canceled` | canceled | No | Subscription ended | | `expired` | canceled | No | Subscription period expired | ## Adding a custom web payment provider EasyStarter's payment layer is built around a `PaymentProvider` interface with Stripe and Creem as built-in implementations. You can swap in any other provider (e.g. [Paddle](https://www.paddle.com/), [LemonSqueezy](https://www.lemonsqueezy.com/)) in six steps without touching any business logic. ### Step 1: Register the provider key Add the new provider key to `SUPPORTED_WEB_PAYMENT_PROVIDERS` in `packages/app-config/src/types.ts`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "creem", "paddle"] as const; ``` This automatically updates the `WebPaymentProviderKey` and `ServerPaymentProviderKey` union types everywhere. ### Step 2: Implement the PaymentProvider interface Create a new directory under `apps/server/src/payments/providers/` and implement the `PaymentProvider` interface: ```ts title="apps/server/src/payments/providers/paddle/provider.ts" import type { CreateCheckoutInput, CreatePortalInput, ParsedWebhookEvent, PaymentProvider, WebhookInput, } from "../../public/types"; export function createPaddlePaymentProvider(): PaymentProvider { return { key: "paddle", async createCheckoutSession(input: CreateCheckoutInput) { // Call Paddle SDK to create a checkout session // Return { providerSessionId, url, expiresAt } }, async createPortalSession(input: CreatePortalInput) { // Return the Paddle subscription management URL // Return { providerSessionId, url } }, async parseWebhookEvent(input: WebhookInput): Promise { // Verify signature and parse payload // Return { providerEventId, type, createdAt, payload } }, async setSubscriptionCancelAtPeriodEnd(input) { // Call Paddle API to set cancel-at-period-end }, async updateSubscriptionPrice(input) { // Call Paddle API to update the subscription price }, }; } ``` Interface method reference: | Method | Description | | --- | --- | | `createCheckoutSession` | Creates a checkout session and returns the redirect URL | | `createPortalSession` | Creates a subscription management portal session | | `parseWebhookEvent` | Verifies the webhook signature and parses the event | | `setSubscriptionCancelAtPeriodEnd` | Schedules a subscription to cancel at period end | | `updateSubscriptionPrice` | Updates the subscription price (used for in-app upgrades) | ### Step 3: Implement the webhook event handler Create event handling logic under `apps/server/src/payments/providers/paddle/webhook/` and map provider events to database operations: ```ts title="apps/server/src/payments/providers/paddle/webhook/handle-event.ts" import type { Database } from "@/db"; export async function handlePaddleEvent(db: Database, payload: unknown) { const event = payload as { event_type: string; data: unknown }; switch (event.event_type) { case "subscription.created": case "subscription.updated": case "subscription.canceled": { // Sync subscription state into the billing_subscription table break; } case "transaction.completed": { // Handle one-time purchases and write to billing_purchase table break; } // Handle other events as needed... default: break; } } ``` > See `apps/server/src/payments/providers/stripe/webhook/` for how to split complex event types across multiple files. ### Step 4: Register the provider in the factory Add the new provider to the factory map in `apps/server/src/payments/providers/index.ts`: ```ts title="apps/server/src/payments/providers/index.ts" import { createPaddlePaymentProvider } from "./paddle/provider"; const providers: Record PaymentProvider> = { stripe: createStripePaymentProvider, creem: createCreemPaymentProvider, paddle: createPaddlePaymentProvider, // add this }; ``` ### Step 5: Add a webhook route Register a dedicated webhook route for the new provider in `apps/server/src/index.ts`: ```ts title="apps/server/src/index.ts" app.post("/api/webhooks/paddle", async (c) => { const context = await createContext({ context: c }); const rawBody = await c.req.text(); const signature = c.req.header("paddle-signature"); await context.payments.handleWebhookEvent({ provider: "paddle", rawBody, signature, }); return c.json({ received: true }); }); ``` The `handleWebhookEvent` service method automatically routes events to the correct `handlePaddleEvent` handler. ### Step 6: Configure pricing plans and switch the provider Update `web.payments.provider` and fill in the new provider's Price IDs in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "paddle", // switch to the new provider plans: [ { id: "pro", prices: [ { id: "monthly", provider: "paddle", providerPriceId: "pri_xxxxxxxxxxxxxxxx", // Paddle Price ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, ``` Once complete, all checkout sessions, upgrades, and portal redirects will go through the new provider automatically — no changes to business logic needed. ## Pre-launch checklist | Item | What to verify | | --- | --- | | API key | Switched to live key `creem_live_` | | Webhook secret | Secret from the production webhook endpoint | | Product IDs | Using Product IDs created in live mode | | Webhook endpoint | Production URL configured in Creem dashboard | | Push secrets | Deployed to Cloudflare Workers via `pnpm run secrets:bulk:production` | # Stripe Payments (http://page.easystarter.dev/docs/web/integrations/payments/stripe) ## Stripe Payment Integration EasyStarter's web app ships with a fully integrated [Stripe](https://stripe.com/) payment system, including: - Subscription checkout (monthly / yearly) - One-time lifetime purchase checkout - Free trial support - Stripe Billing Portal (self-serve subscription management, cancellations, payment method updates, invoices) - In-app plan upgrades (monthly → yearly, without redirecting to Checkout) - Webhook event handling (subscription sync, invoices, refunds, disputes, and more) Payment setup is split into two parts: 1. **Environment variables**: API keys and webhook signing secret, added to `.dev.vars` / `.env.production` 2. **Pricing plans**: Stripe Price IDs and pricing metadata configured in `packages/app-config/src/app-config.ts` ## Required Environment Variables ```bash STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= ``` ### Register on Stripe and get your API key Official docs: [Stripe API Keys](https://dashboard.stripe.com/apikeys) 1. Go to [stripe.com](https://stripe.com/) and create an account 2. Log in and navigate to **[Developers → API keys](https://dashboard.stripe.com/apikeys)** 3. Copy the **Secret key** (test keys start with `sk_test_`, live keys with `sk_live_`) > Start with the test key during development. Switch to **Live mode** in the same page before going to production. Fill in the copied key: ```bash title="apps/server/.dev.vars" STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### Create products and prices in Stripe EasyStarter defaults to three plans: **Free**, **Pro** (monthly + yearly), and **Lifetime** (one-time purchase). The **Free** plan requires no Stripe product. Follow these steps to create Pro and Lifetime: Official docs: [Stripe Products & Prices](https://dashboard.stripe.com/products) 1. Go to **[Products](https://dashboard.stripe.com/products)** and click **Add product** 2. Enter a product name, e.g. `Pro` 3. In the **Pricing** section: - Choose **Recurring** (subscription), set your price and billing period (**Monthly** or **Yearly**) - Click **More price options** to add multiple prices for the same product 4. After saving, click into each price's detail page and copy its **Price ID** (format: `price_xxxxxxx`) 5. Repeat for **Lifetime**, choosing **One time** as the pricing type Each price gets a unique Price ID, which you'll use in the next step. ### Configure pricing plans Add the Price IDs from the previous step to the `web.payments.plans` field in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "stripe", // test/prod hold environment-specific Stripe Price IDs. // Use test-mode price_... with sk_test_, and live-mode price_... with sk_live_. plans: [ { id: "free", // Free plan — no Price ID required }, { id: "pro", prices: [ { id: "monthly", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe monthly Price ID for test mode }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe monthly Price ID for live mode }, currency: "usd", amountCents: 1000, // $10.00 priceType: "subscription", interval: "month", trialDays: 7, // Free trial days — remove if not needed status: "active", }, { id: "yearly", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe yearly Price ID for test mode }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe yearly Price ID for live mode }, currency: "usd", amountCents: 10000, // $100.00 priceType: "subscription", interval: "year", trialDays: 7, status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe one-time Price ID for test mode }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // Stripe one-time Price ID for live mode }, currency: "usd", amountCents: 20000, // $200.00 priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` Field reference: | Field | Description | | --- | --- | | `test.providerPriceId` | Stripe Price ID from test mode / sandbox, format `price_xxx` | | `prod.providerPriceId` | Stripe Price ID from live mode / production, format `price_xxx` | | `amountCents` | Price in cents — `1000` = $10.00 | | `priceType` | `"subscription"` for recurring, `"lifetime"` for one-time | | `interval` | Billing cycle: `"month"` or `"year"` (omit for lifetime) | | `trialDays` | Free trial length in days — remove the field to disable trials | | `status` | `"active"` to show / `"archived"` to hide from the pricing page | ### Configure Stripe Webhooks Webhooks are how Stripe notifies your server about events like successful payments, subscription changes, and invoices. They are essential for keeping your database in sync. **Local development (using Stripe CLI):** 1. Install the [Stripe CLI](https://docs.stripe.com/stripe-cli): ```bash # macOS brew install stripe/stripe-cli/stripe ``` 2. Log in to Stripe CLI: ```bash stripe login ``` 3. Forward events to your local server (default port `3001`): ```bash stripe listen --forward-to http://localhost:3001/api/webhooks/stripe ``` 4. The CLI will output a **Webhook signing secret** starting with `whsec_`. Copy it into: ```bash title="apps/server/.dev.vars" STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` **Production (Stripe Dashboard):** 1. Go to **[Developers → Webhooks](https://dashboard.stripe.com/webhooks)** 2. Click **Add endpoint** 3. Set the **Endpoint URL** to your production server: `https://your-server.workers.dev/api/webhooks/stripe` 4. Under **Events to send**, select the following events (all handled by EasyStarter): | Event | Description | | --- | --- | | `checkout.session.completed` | Checkout completed | | `checkout.session.async_payment_succeeded` | Async payment succeeded | | `checkout.session.async_payment_failed` | Async payment failed | | `checkout.session.expired` | Checkout session expired | | `customer.subscription.created` | Subscription created | | `customer.subscription.updated` | Subscription updated | | `customer.subscription.deleted` | Subscription cancelled | | `payment_intent.succeeded` | Payment intent succeeded | | `payment_intent.payment_failed` | Payment intent failed | | `payment_intent.canceled` | Payment intent cancelled | | `invoice.paid` | Invoice paid | | `invoice.payment_failed` | Invoice payment failed | | `invoice.marked_uncollectible` | Invoice marked uncollectible | | `invoice.voided` | Invoice voided | | `charge.dispute.created` | Dispute opened | | `charge.dispute.updated` | Dispute updated | | `charge.dispute.closed` | Dispute closed | | `charge.refunded` | Charge refunded | 5. After saving, click into the endpoint detail and copy the **Signing secret** (`whsec_xxx`) into: ```bash title="apps/server/.env.production" STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### Set environment variables and start the server Confirm both variables are set in your dev environment: ```bash title="apps/server/.dev.vars" STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Start the server: ```bash pnpm dev:server ``` Keep the Stripe CLI forwarding running in a separate terminal to fully test the payment flow locally. > **Test card number**: Use `4242 4242 4242 4242` with any future expiry date and any 3-digit CVV in Stripe test mode. > More test cards at [Stripe Testing Docs](https://docs.stripe.com/testing). ## Billing route configuration The redirect URLs after a successful or cancelled payment are configured in `web.routes` inside `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { routes: { billingSuccess: "/billing/success", // Redirect after successful payment billingCancel: "/billing/cancel", // Redirect after cancelled payment billingReturn: "/settings/billing", // Redirect after leaving Billing Portal }, }, ``` ## Billing Portal (self-serve subscription management) EasyStarter includes Stripe Billing Portal integration out of the box. Users can manage their subscriptions from `/settings/billing`, where the app creates a portal session and redirects them to Stripe's hosted management UI. Before using it in production, configure the portal in your Stripe dashboard: 1. Go to **[Customer Portal Settings](https://dashboard.stripe.com/settings/billing/portal)** 2. Enable the features you want — cancellations, payment method updates, invoice history, etc. 3. Save the configuration ## Pre-launch checklist | Item | What to verify | | --- | --- | | API key | Switched to Live mode `sk_live_` key | | Webhook secret | Signing secret from the production webhook endpoint | | Price IDs | Using Price IDs created in Live mode | | Billing Portal | Configured and enabled in Stripe Dashboard | | Push secrets | Deployed to Cloudflare Workers via `pnpm run secrets:bulk:production` | ## Adding a custom web payment provider EasyStarter's payment layer is built around a `PaymentProvider` interface with Stripe as the default implementation. You can swap in any other provider (e.g. [Paddle](https://www.paddle.com/), [LemonSqueezy](https://www.lemonsqueezy.com/)) in six steps without touching any business logic. ### Step 1: Register the provider key Add the new provider key to `SUPPORTED_WEB_PAYMENT_PROVIDERS` in `packages/app-config/src/types.ts`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "paddle"] as const; ``` This automatically updates the `WebPaymentProviderKey` and `ServerPaymentProviderKey` union types everywhere. ### Step 2: Implement the PaymentProvider interface Create a new directory under `apps/server/src/payments/providers/` and implement the `PaymentProvider` interface: ```ts title="apps/server/src/payments/providers/paddle/provider.ts" import type { CreateCheckoutInput, CreatePortalInput, ParsedWebhookEvent, PaymentProvider, WebhookInput, } from "../../public/types"; export function createPaddlePaymentProvider(): PaymentProvider { return { key: "paddle", async createCheckoutSession(input: CreateCheckoutInput) { // Call Paddle SDK to create a checkout session // Return { providerSessionId, url, expiresAt } }, async createPortalSession(input: CreatePortalInput) { // Return the Paddle subscription management URL // Return { providerSessionId, url } }, async parseWebhookEvent(input: WebhookInput): Promise { // Verify signature and parse payload // Return { providerEventId, type, createdAt, payload } }, async setSubscriptionCancelAtPeriodEnd(input) { // Call Paddle API to set cancel-at-period-end }, async updateSubscriptionPrice(input) { // Call Paddle API to update the subscription price }, }; } ``` Interface method reference: | Method | Description | | --- | --- | | `createCheckoutSession` | Creates a checkout session and returns the redirect URL | | `createPortalSession` | Creates a subscription management portal session | | `parseWebhookEvent` | Verifies the webhook signature and parses the event | | `setSubscriptionCancelAtPeriodEnd` | Schedules a subscription to cancel at period end | | `updateSubscriptionPrice` | Updates the subscription price (used for in-app upgrades) | ### Step 3: Implement the webhook event handler Create event handling logic under `apps/server/src/payments/providers/paddle/webhook/` and map provider events to database operations: ```ts title="apps/server/src/payments/providers/paddle/webhook/handle-event.ts" import type { Database } from "@/db"; export async function handlePaddleEvent(db: Database, payload: unknown) { const event = payload as { event_type: string; data: unknown }; switch (event.event_type) { case "subscription.created": case "subscription.updated": case "subscription.canceled": { // Sync subscription state into the billing_subscription table break; } case "transaction.completed": { // Handle one-time purchases and write to billing_purchase table break; } // Handle other events as needed... default: break; } } ``` > See `apps/server/src/payments/providers/stripe/webhook/` for how to split complex event types across multiple files. ### Step 4: Register the provider in the factory Add the new provider to the factory map in `apps/server/src/payments/providers/index.ts`: ```ts title="apps/server/src/payments/providers/index.ts" import { createPaddlePaymentProvider } from "./paddle/provider"; const providers: Record PaymentProvider> = { stripe: createStripePaymentProvider, paddle: createPaddlePaymentProvider, // add this }; ``` ### Step 5: Add a webhook route Register a dedicated webhook route for the new provider in `apps/server/src/index.ts`: ```ts title="apps/server/src/index.ts" app.post("/api/webhooks/paddle", async (c) => { const context = await createContext({ context: c }); const rawBody = await c.req.text(); const signature = c.req.header("paddle-signature"); await context.payments.handleWebhookEvent({ provider: "paddle", rawBody, signature, }); return c.json({ received: true }); }); ``` The `handleWebhookEvent` service method automatically routes events to the correct `handlePaddleEvent` handler. ### Step 6: Configure pricing plans and switch the provider Update `web.payments.provider` and fill in the new provider's Price IDs in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "paddle", // switch to the new provider plans: [ { id: "pro", prices: [ { id: "monthly", provider: "paddle", providerPriceId: "pri_xxxxxxxxxxxxxxxx", // Paddle Price ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, ``` Once complete, all checkout sessions, upgrades, and portal redirects will go through the new provider automatically — no changes to business logic needed. # Waffo Payments (http://page.easystarter.dev/docs/web/integrations/payments/waffo) ## Waffo Pancake Payment Integration EasyStarter's web app includes [Waffo Pancake](https://pancake.waffo.ai/) payment support for **web-only** scenarios, including: - Hosted subscription checkout (monthly / yearly) - Hosted one-time lifetime purchase checkout - Waffo product trials - Waffo Consumer Portal - Cancellation at the end of the billing period - Webhook handling for orders, subscriptions, renewals, and refunds Payment setup is split into two parts: 1. **Environment variables**: Merchant ID, private key, and runtime environment in the server's `.dev.vars` / `.env.production` 2. **Pricing plans**: Waffo Product IDs and pricing metadata in `packages/app-config/src/app-config.ts` > The Waffo SDK is server-side only. Never expose `WAFFO_PRIVATE_KEY` through web environment variables or frontend code, and never commit it to Git. ## Required environment variables ```bash WAFFO_MERCHANT_ID= WAFFO_PRIVATE_KEY= WAFFO_ENVIRONMENT=test ``` | Variable | Description | | --- | --- | | `WAFFO_MERCHANT_ID` | Merchant ID in the `MER_xxx` format; this is not a Store ID | | `WAFFO_PRIVATE_KEY` | RSA private key for the current environment, used only by the server SDK | | `WAFFO_ENVIRONMENT` | Webhook verification environment: `test` in development and `prod` in production | ### Get the Merchant ID and test private key 1. Sign in to the [Waffo Pancake Merchant Dashboard](https://pancake.waffo.ai/merchant/dashboard/integration) 2. Open **Integration** and select **Test Mode** 3. Copy the **Merchant ID** at the top of the page (`MER_xxx`) 4. Create a test key under **Create API Key**, then copy the private key or use **Copy .env config** 5. Add the credentials to the local server environment: ```bash title="apps/server/.dev.vars" WAFFO_MERCHANT_ID=MER_xxxxxxxxxxxxxxxxxxxxxxxx WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" WAFFO_ENVIRONMENT=test ``` > Waffo isolates test and production API keys. When switching the Dashboard environment, replace both the private key and `WAFFO_ENVIRONMENT`; otherwise API requests or Webhook verification will fail. Keep the PEM format copied from the Dashboard. For a single-line `.env` value, preserve the quotes and escaped `\n` line breaks; the Waffo SDK normalizes the PEM value automatically. ### Create products in Waffo EasyStarter defaults to three plans: **Free**, **Pro** (monthly + yearly), and **Lifetime** (one-time purchase). The Free plan does not require a Waffo product. Open **Products** in the Merchant Dashboard and create these three products: | EasyStarter price | Waffo product type | Waffo billing period | | --- | --- | --- | | Pro Monthly | Subscription | Monthly | | Pro Yearly | Subscription | Yearly | | Lifetime | One-time | Not applicable | After saving each product, copy its **Product ID** (`PROD_xxx`). EasyStarter stores Waffo Product IDs in the shared `providerPriceId` field. If you offer a trial, configure its duration on the Waffo subscription product or product group and keep the next step's `trialDays` value in sync. ### Configure pricing plans Add the Waffo Product IDs to `web.payments.plans` in `packages/app-config/src/app-config.ts`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "waffo", plans: [ { id: "free", }, { id: "pro", prices: [ { id: "monthly", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test monthly Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production monthly Product ID }, currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", trialDays: 7, status: "active", }, { id: "yearly", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test yearly Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production yearly Product ID }, currency: "usd", amountCents: 10000, priceType: "subscription", interval: "year", trialDays: 7, status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test one-time Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production one-time Product ID }, currency: "usd", amountCents: 20000, priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` Field reference: | Field | Description | | --- | --- | | `provider` | Set both the web default and every price provider to `"waffo"` | | `test.providerPriceId` | Waffo Product ID available in the test environment (`PROD_xxx`) | | `prod.providerPriceId` | Published Waffo Product ID available in production (`PROD_xxx`) | | `amountCents` | Amount displayed by EasyStarter in cents; it must match the Waffo product price | | `priceType` | `"subscription"` for recurring or `"lifetime"` for a one-time purchase | | `interval` | `"month"` or `"year"` for subscriptions; omit for Lifetime | | `trialDays` | A non-empty value asks checkout to enable a trial; Waffo controls the actual duration | | `status` | `"active"` to enable or `"archived"` to hide the price | > Waffo product APIs use display amounts such as `"10.00"`. EasyStarter's local catalog still uses cents, so `$10.00` is `amountCents: 1000`. ### Configure Waffo Webhooks Webhooks are required to grant entitlements after checkout and keep subscription state synchronized. 1. Open **Settings → Webhooks** in the Waffo Merchant Dashboard 2. Add an **HTTP** Webhook and select the test or production environment that matches the current credentials 3. Set the Endpoint URL: - Local development: `https://your-ngrok-url/api/webhooks/waffo` - Production: `https://your-server.workers.dev/api/webhooks/waffo` 4. Subscribe to the events handled by EasyStarter: | Event | EasyStarter behavior | | --- | --- | | `order.completed` | Complete a one-time purchase or credit package order | | `subscription.activated` | Activate the subscription | | `subscription.payment_succeeded` | Synchronize a successful renewal | | `subscription.canceling` | Mark cancellation at period end and keep current access | | `subscription.uncanceled` | Restore a subscription that was scheduled to cancel | | `subscription.updated` | Synchronize subscription changes | | `subscription.canceled` | Mark the subscription as terminated | | `subscription.past_due` | Mark a failed renewal as past due | | `refund.succeeded` | Revoke the corresponding lifetime or credit entitlement | | `refund.failed` | Record delivery without changing entitlements | Waffo signs the raw request body with RSA-SHA256 and sends the signature in `x-waffo-signature`. EasyStarter reads the raw text and verifies it with the Waffo SDK, so no separate Webhook secret is required. > Use [ngrok](https://ngrok.com/) to forward the Server's `3001` port during local development. Avoid tunnel services that strip custom request headers, because losing `x-waffo-signature` makes verification impossible. ```bash ngrok http 3001 ``` ### Start the app and complete a sandbox checkout Start the Web and Server apps: ```bash pnpm dev:web+server ``` Open the pricing page and complete a test checkout. Waffo checkout opens in a new tab so EasyStarter keeps the current merchant page state. | Scenario | Test card number | | --- | --- | | Successful payment | `4576 7500 0000 0110` | | Declined payment | `4576 7500 0000 0220` | Use any future expiry date and any CVC. After payment, confirm the Server receives `POST /api/webhooks/waffo` with a `200` response, then check the subscription or lifetime entitlement at `/settings/billing`. ## Production setup Complete these checks before launch: 1. Switch the Waffo Dashboard to **Live Mode**, then create and copy a separate production private key 2. Publish the required products and confirm every `prod.providerPriceId` points to a Product ID available in production 3. Register `https://your-server.workers.dev/api/webhooks/waffo` in the production environment 4. Add the production server variables: ```bash title="apps/server/.env.production" WAFFO_MERCHANT_ID=MER_xxxxxxxxxxxxxxxxxxxxxxxx WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" WAFFO_ENVIRONMENT=prod ``` 5. Follow [Deploy Server](/docs/web/deploy-server) to upload production secrets and deploy Do not reuse the test private key in production, and do not configure a production Product ID before that product has been published. ## Consumer Portal and current limitations Users can open the [Waffo Consumer Portal](https://pancake.waffo.ai/consumer/portal/login) from `/settings/billing`. The current EasyStarter integration has these capability boundaries: - Cancellation at the end of the current billing period is supported - A buyer must undo a scheduled cancellation in the Waffo portal - Waffo's [Change Subscription Product endpoint](https://docs.waffo.ai/zh/api-reference/endpoints/subscriptions/change-product) is not implemented yet and currently always returns `501 Not Implemented`, so EasyStarter cannot offer in-app subscription upgrades or downgrades until Waffo makes this platform capability available - Waffo Webhooks still synchronize uncancellations and subscription changes completed in the portal For platform details, see the [official Waffo SDK integration guide](https://docs.waffo.ai/integrate/skill) and [Webhook guide](https://docs.waffo.ai/guides/webhooks). # Administrators and RBAC (http://page.easystarter.dev/docs/web/integrations/rbac) EasyStarter includes global RBAC with two roles: `user` and `admin`. Each account stores exactly one role. ## Configure RBAC ### Enable admin features Update `appConfig.common` in `packages/app-config/src/app-config.ts`: ```ts common: { admin: { // Enable paid-user management paidUsers: { enabled: true, }, // Enable user management and administrative operation history userManagement: { enabled: true, }, }, auth: { // Other authentication settings… rbac: { defaultRole: "user", adminRoles: ["admin"], }, }, } ``` `userManagement.enabled` controls user management and administrative operation history. `paidUsers.enabled` controls paid-user management. Disabling a feature removes both its pages and APIs. Keep `defaultRole` set to `"user"`. Changing it to `"admin"` would grant administrative permissions to every new account. ### Configure the initial administrator email Set `ADMIN_EMAIL` to the real account email that will sign in to the admin area: ```bash title="apps/server/.dev.vars" ADMIN_EMAIL=admin@yourcompany.com ``` For production, set it in: ```bash title="apps/server/.env.production" ADMIN_EMAIL=admin@yourcompany.com ``` Then upload the Cloudflare secret: ```bash pnpm -F server secrets:bulk:production ``` `ADMIN_EMAIL` is not the sender address or `supportEmail`. It must match the verified email of the account that signs in. Only one email is supported. Do not provide a comma-separated list. After the initial administrator signs in, use user management to assign the `admin` role to other accounts. ### Activate the administrator role After setting `ADMIN_EMAIL`, redeploy the Server. If the user has already signed in, ask them to sign out and sign in again. The Server updates the account's `role` to `admin` when it creates the new session. ## Default permissions | Permission | Purpose | `user` | `admin` | | -------------------------- | ------------------------------- | ------ | ------- | | `admin:access` | Enter administrative areas | ✗ | ✓ | | `user:list` | List users | ✗ | ✓ | | `user:set-role` | Change user roles | ✗ | ✓ | | `user:ban` | Ban and unban users | ✗ | ✓ | | `credits:adjust` | Adjust credits | ✗ | ✓ | | `membership:grant-trial` | Grant a Membership trial | ✗ | ✓ | | `operation:list` | View administrative operations | ✗ | ✓ | The permission vocabulary and role matrix live in `packages/app-config/src/rbac/index.ts`. ## How Web uses RBAC The Web sidebar checks both the feature switch and the current user's permission. Each route repeats the check in `beforeLoad`: ```tsx beforeLoad: ({ context }) => { if (!webConfig.adminUserManagementEnabled) { throw notFound(); } if (!hasPermission(context.user.role, "user", "list")) { throw redirect({ to: "/forbidden" }); } }, ``` Client checks are only for navigation and user experience. The server must enforce the permission independently. ```ts import { assertPermission, protectedProcedure } from "@/lib/orpc"; export const adjustCredits = protectedProcedure.handler(async ({ context }) => { assertPermission(context, "credits", "adjust"); // Business logic }); ``` For general administrative access, use `adminProcedure`. It requires the current account to have `admin:access`. ## Revoke an administrator Removing or changing `ADMIN_EMAIL` does not revoke an existing administrator. Change the old administrator's role back to `user` in user management before updating the environment variable. # Alibaba Cloud OSS (Recommended for China) (http://page.easystarter.dev/docs/web/integrations/storage/aliyun-oss) ## Alibaba Cloud OSS Storage EasyStarter ships with Alibaba Cloud [Object Storage Service (OSS)](https://www.alibabacloud.com/help/en/oss/) as a built-in storage provider. You can switch between OSS and the default Cloudflare R2 at any time. The server talks to OSS directly through the OSS REST API V4 with `OSS4-HMAC-SHA256` signing — no Node.js SDK is required, so it runs natively on the Cloudflare Workers runtime. If your product is mostly used inside mainland China, Alibaba Cloud OSS usually gives more stable latency and cheaper egress than Cloudflare R2. It also reuses the same RAM AccessKey as the [Alibaba Cloud phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth) integration, which keeps operations simple. | Item | Current setup | | --- | --- | | Upload / download / list / delete | OSS REST API V4 with `OSS4-HMAC-SHA256` signing | | Server provider | `apps/server/src/storage/providers/aliyun-oss.ts` | | Provider registration | `apps/server/src/storage/index.ts` | | Provider switch | `common.storage.provider` in `packages/app-config/src/app-config.ts` | | Public access path | `${SERVER_URL}/api/storage/aliyun-oss/` (proxied by the server; the bucket itself stays private) | The existing `avatar` and `attachment` upload types, MIME allowlists, and size limits are provider-agnostic. Switching to OSS does not require any change to business code. ## Required Environment Variables ```bash # Shared with the Alibaba Cloud phone sign-in integration ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= # OSS-specific ALIYUN_OSS_BUCKET= ALIYUN_OSS_REGION= ALIYUN_OSS_ENDPOINT= ``` What each variable means: | Variable | Meaning | Example | | --- | --- | --- | | `ALIBABA_CLOUD_ACCESS_KEY_ID` | RAM user AccessKey ID, the long-term credential used by the server | `LTAI5tXXXXXXXXXXXXXXXXX` | | `ALIBABA_CLOUD_ACCESS_KEY_SECRET` | RAM user AccessKey Secret, shown only once on creation | `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` | | `ALIYUN_OSS_BUCKET` | OSS bucket name, without any domain | `your-app-bucket` | | `ALIYUN_OSS_REGION` | Region ID of the bucket, used as the region scope when signing | `cn-hangzhou` | | `ALIYUN_OSS_ENDPOINT` | OSS access domain — **must not include the bucket name or the protocol** | `oss-cn-hangzhou.aliyuncs.com` | `ALIYUN_OSS_ENDPOINT` must be the region-level public endpoint, e.g. `oss-cn-hangzhou.aliyuncs.com`. If you set it to `your-app-bucket.oss-cn-hangzhou.aliyuncs.com`, the provider will prepend the bucket again and produce an invalid host. If you already configured `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` for [phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth), you can reuse the same AccessKey — just grant the existing RAM user additional OSS permissions. ### Activate Object Storage Service Make sure your Alibaba Cloud account has OSS activated and identity verification completed. Product page: [Alibaba Cloud Object Storage Service (OSS)](https://www.alibabacloud.com/product/oss). Click **Buy Now** / **Activate Now** at the top of the page to enable the service. ### Create an OSS bucket Official docs: [Create a bucket](https://www.alibabacloud.com/help/en/oss/user-guide/create-a-bucket-4) 1. Log in to the [OSS Console](https://oss.console.aliyun.com/) 2. Click **Buckets** → **Create Bucket** 3. Enter a **Bucket name**, e.g. `your-app-bucket` (globally unique, 3-63 characters, lowercase letters, digits, and hyphens only) 4. Choose a **Region**, e.g. `China (Hangzhou)`, which maps to the region ID `cn-hangzhou` 5. Keep **ACL** as the default **Private** — files are served through a server-side proxy, the bucket does not need public access 6. Accept defaults for the rest and click **OK** Record the following: - Bucket name → `ALIYUN_OSS_BUCKET` - Region ID (the part after `oss-` in the bucket overview, e.g. `cn-hangzhou` in `oss-cn-hangzhou`) → `ALIYUN_OSS_REGION` - Public endpoint (the **Endpoint (External)** field on the bucket overview, e.g. `oss-cn-hangzhou.aliyuncs.com`) → `ALIYUN_OSS_ENDPOINT` ### Grant the RAM user OSS permissions Use a RAM user AccessKey instead of an Alibaba Cloud root account AccessKey. If you already created a RAM user for [phone sign-in](/docs/web/integrations/authentication/aliyun-phone-auth), simply attach an additional policy to the same user. 1. Log in to the [Alibaba Cloud RAM Console](https://ram.console.aliyun.com/) 2. Go to **Identities** → **Users** and select the target RAM user 3. Open **Permissions** → **Grant Permission** 4. Set **Resource Scope** to **Account**, search and check the system policy **`AliyunOSSFullAccess`** under **Policy**, then click **OK** to grant the permission ![Select the AliyunOSSFullAccess system policy in the RAM grant-permission panel](/images/docs/aliyun-oss-ram-policy.png) This is the approach Alibaba Cloud officially recommends. Once the system policy is attached, the RAM user can read and write OSS objects. ### Create or reuse the AccessKey Official docs: [Create an AccessKey pair](https://www.alibabacloud.com/help/en/ram/user-guide/create-an-accesskey-pair) If you do not have an AccessKey yet: 1. Open the **Authentication** or **AccessKey** tab on the RAM user detail page 2. Click **Create AccessKey** and complete the security challenge 3. Copy and save immediately: - `AccessKey ID` → `ALIBABA_CLOUD_ACCESS_KEY_ID` - `AccessKey Secret` → `ALIBABA_CLOUD_ACCESS_KEY_SECRET` The `AccessKey Secret` is shown only once. If you lose it, you must disable the old AccessKey and create a new one. If you already configured a RAM user AccessKey for phone sign-in, reuse it instead of creating another AccessKey for the same user. ### Switch the storage provider In `packages/app-config/src/app-config.ts`, change `common.storage.provider` from `"r2"` to `"aliyun-oss"`: ```ts title="packages/app-config/src/app-config.ts" storage: { enabled: true, provider: "aliyun-oss", // change from "r2" to "aliyun-oss" publicPath: "/api/storage", // ...rest unchanged }, ``` After this change, every `avatar` / `attachment` upload, download, list, and delete routes through the OSS provider automatically. Business code, upload components, and Better Auth avatar logic do not need to change. ### Fill in local and production environment variables For local development, add to `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` For production deployment, add to `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` After switching to OSS, `R2_PUBLIC_URL` is no longer needed and can be removed from both files. The `r2_buckets` binding (`STORAGE`) in `apps/server/wrangler.jsonc` is unused as well — you can keep it so you can switch back, or remove it. ### Push secrets to production Before deploying to Cloudflare Workers, push the secrets: ```bash pnpm -F server secrets:bulk:production ``` After a successful push, the Worker runtime reads these values via `env.ALIBABA_CLOUD_ACCESS_KEY_ID`, `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`, `env.ALIYUN_OSS_BUCKET`, `env.ALIYUN_OSS_REGION`, and `env.ALIYUN_OSS_ENDPOINT`. Re-run this command whenever any of those values change — you do not need to redeploy code just because a secret changed. ### Verify uploads locally Start the server and the web client: ```bash pnpm dev:server pnpm dev:web ``` Sign in and upload an avatar from the profile page. The frontend posts the file to `/api/storage/upload`, the server calls the OSS provider's `put` method and writes the file under `avatars//...`, and returns a public URL similar to: ``` http://localhost:3001/api/storage/aliyun-oss/avatars//.png ``` Reads are proxied back through `/api/storage/aliyun-oss/`, so the bucket itself stays private. You can confirm the object in the OSS Console **Files** view, and opening the public URL above in a browser should render the image. # Object Storage (http://page.easystarter.dev/docs/web/integrations/storage) ## Object Storage EasyStarter uses [Cloudflare R2](https://developers.cloudflare.com/r2/) as its object storage service for uploading and managing user files. Two upload types are supported out of the box: | Upload type | Description | Size limit | | --- | --- | --- | | `avatar` | User profile picture | 5 MB | | `attachment` | Attachments (images, PDFs, plain text) | 25 MB | ### Create an R2 Bucket Official docs: [R2 Getting started](https://developers.cloudflare.com/r2/get-started/) **Option 1: via Cloudflare Dashboard** 1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **R2 Object Storage** 3. Click **Create bucket** 4. Enter a bucket name, e.g. `your-app-bucket` 5. Choose a location (Automatic is recommended) 6. Click **Create bucket** **Option 2: via Wrangler CLI** ```bash pnpm wrangler r2 bucket create your-app-bucket ``` ### Configure wrangler.jsonc Fill in your bucket name in the `r2_buckets` field of `apps/server/wrangler.jsonc`: ```jsonc title="apps/server/wrangler.jsonc" "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-app-bucket" } ], ``` - `binding` must stay `STORAGE` — this is the variable name used to access R2 inside the Worker. Do not change it. - `bucket_name` should be the name of the bucket you created in R2. ### Enable public access and get R2_PUBLIC_URL After uploading files, you need a public URL to serve them. The recommended approach is to use R2's built-in **Public Access** feature. **Enable via Cloudflare Dashboard (recommended)** 1. Open your R2 bucket detail page 2. Click the **Settings** tab 3. Under **Public Access**, click **Allow Access** 4. A public URL will be generated automatically in the format: `https://pub-xxxxxxxx.r2.dev` This URL is your `R2_PUBLIC_URL`. **Custom domain (optional)** You can also bind a custom domain under bucket Settings → Custom Domains, e.g. `https://cdn.yourdomain.com`. Use the custom domain as `R2_PUBLIC_URL` after binding. ### Set the R2_PUBLIC_URL environment variable `R2_PUBLIC_URL` must be added to two environment variable files: **Local development** (`apps/server/.dev.vars`): ```bash title="apps/server/.dev.vars" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` **Production deployment** (`apps/server/.env.production`): ```bash title="apps/server/.env.production" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` `.env.production` is used to bulk-push secrets to Cloudflare Workers via `pnpm run secrets:bulk:production`. It does not participate in the build directly. ### Configure storage parameters (optional) Storage parameters are defined in `common.storage` inside `packages/app-config/src/app-config.ts` and can be adjusted as needed: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "r2", publicPath: "/api/storage", // API path prefix for serving files keyPrefixes: { avatar: "avatars", // Key prefix for avatar files attachment: "attachments", // Key prefix for attachment files }, fallbackPrefix: "files", // Fallback prefix for unclassified uploads allowedTypes: { avatar: ["image/jpeg", "image/png", "image/gif", "image/webp"], attachment: ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"], }, maxFileSizes: { avatar: 5 * 1024 * 1024, // 5 MB attachment: 25 * 1024 * 1024, // 25 MB }, }, ``` ## Extending to other storage providers EasyStarter's storage layer is built around the `StorageProvider` interface. Plugging in any storage service (e.g. [AWS S3](https://aws.amazon.com/s3/), [MinIO](https://min.io/)) takes four steps. ### Step 1: Register the provider key Suppose using S3 as an example. Add the new provider key to `SUPPORTED_STORAGE_PROVIDERS` in `packages/app-config/src/types.ts`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_STORAGE_PROVIDERS = ["r2", "s3"] as const; ``` ### Step 2: Implement the provider Create a new file in `apps/server/src/storage/providers/` that implements the `StorageProvider` interface: ```ts title="apps/server/src/storage/providers/s3.ts" import type { StorageProvider } from "../types"; export function createS3StorageProvider({ client, bucket }: { client: S3Client; bucket: string; }): StorageProvider { return { async put(key, data, options) { // Call the S3 SDK to upload }, async get(key) { // Call the S3 SDK to download }, async head(key) { // Call the S3 SDK to get metadata }, async delete(key) { // Call the S3 SDK to delete }, }; } ``` ### Step 3: Register it in the storage providers map Add the new provider to the `providers` map in `apps/server/src/storage/index.ts`: ```ts title="apps/server/src/storage/index.ts" import { createS3StorageProvider } from "./providers/s3"; const providers: Record = { r2: createR2StorageProvider({ bucket: storage }), s3: createS3StorageProvider({ client: s3Client, bucket: "your-bucket" }), }; ``` ### Step 4: Switch the configuration Update `storage.provider` in `packages/app-config/src/app-config.ts` to the new provider key: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "s3", // switch to the new provider // ...other fields remain unchanged }, ``` Once done, all file upload, download, and delete operations will automatically route through the new provider — no changes to business logic required. # Project Structure (http://page.easystarter.dev/docs/web/project-structure) import { File, Files, Folder } from "fumadocs-ui/components/files"; Overview [#overview] * `apps/*`: Apps for each platform * `packages/*`: Reusable code across platforms * `CLAUDE.md`: Claude Code assistant md file * `AGENTS.md`: Codex agent md file Top-level Directory [#top-level-directory] * `apps/web`: Web SaaS frontend, documentation site, and blog content * `apps/server`: Hono + Cloudflare Workers backend * `apps/native`: Expo mobile platform * `packages/app-config`: Unified business configuration, especially for payment and storage strategies * `packages/api-client`: Provides shared API clients * `packages/i18n`: Provides multi-language resources * `packages/shared`: Tools and base types for cross-platform use apps Directory [#apps-directory] The `apps` directory contains three applications targeting real running environments. * `web`: For browsers, including marketing pages, blogs, documentation, authentication, and the dashboard. * `server`: For APIs, authentication, payments, emails, databases, and storage. * `native`: For mobile platforms, reusing the same backend and shared configurations. packages Directory [#packages-directory] The `packages` directory is used for cross-application shared logic, avoiding duplicate implementations for Web, Server, and Native. * `app-config`: The business rules center, ideal for payment plans, feature flags, and storage provider selection. * `api-client`: Allows Web and Native to call the server in a unified way. * `i18n`: Unified management of multi-language messages. * `shared`: For general capabilities that do not depend on a specific platform. Web Application [#web-application] If you are primarily working on the Web platform, your core focus will be the `apps/web` application. * `content/`: Content layer for blogs, authors, categories, and documentation. * `public/`: Static resources like images, favicons, OG images, etc. * `scripts/`: Scripts for content validation and other tasks. * `src/`: Main code for the Web application. * `e2e/`: End-to-end tests. * `source.config.ts`, `vite.config.ts`, `wrangler.jsonc`: Configurations for Web building and deployment. Server Application [#server-application] Backend capabilities are concentrated in `apps/server`. * `src/`: Main code for the server. * `drizzle.config.ts`: Database tool configuration. * `wrangler.jsonc`: Cloudflare Workers configuration. Native Application [#native-application] Mobile platform capabilities are concentrated in `apps/native`. * `app/`: Entry point for mobile pages. * `components/` and `features/`: UI components and business modules. * `themes/`: Theme styles. * `lib/`: Connects basic capabilities. Documentation and Content [#documentation-and-content] Documentation, blogs, and author profiles are stored together in the content directory of the Web application. * `content/docs/*`: Documentation content. * `content/blog/*`: Blog content. * `content/author/*`: Author profiles. * `content/category/*`: Blog categories. Reading Suggestions [#reading-suggestions] * To understand product boundaries, start with the top-level directory and `apps/*`. * To understand shared capabilities, focus on `packages/*`. * To edit documentation or blogs, simply go to `apps/web/content`. # Complete Video Tutorial (http://page.easystarter.dev/docs/web/video-tutorial) # Skills (http://page.easystarter.dev/docs/mobile/ai-prompts) 使用方式 [#使用方式] 在 EasyStarter 项目根目录打开你的 AI 编程助手,输入 Skill 名称和你的要求: ```text $easystarter-mobile-quick-launch 按推荐配置帮我上线移动端应用。 ``` Mobile [#mobile] | 任务 | 命令 | | ------------- | ----------------------------------------------------------------- | | 快速上线 | `$easystarter-mobile-quick-launch 按推荐配置帮我上线移动端应用。` | | 启动模拟器开发 | `$easystarter-mobile-dev-simulator-server 启动移动端和 Server,使用模拟器开发。` | | 启动真机开发 | `$easystarter-mobile-real-device-server 启动移动端和 Server,使用真机开发。` | | 配置应用标识 | `$easystarter-mobile-app-config 配置 app.json 和应用标识。` | | 配置 Cloudflare | `$easystarter-mobile-cloudflare 为移动端 Server 配置 Cloudflare。` | | 配置数据库 | `$easystarter-mobile-database 配置 D1 数据库。` | | 配置邮件 | `$easystarter-mobile-resend-email 为移动端配置 Resend 邮件。` | | 配置认证 | `$easystarter-mobile-auth 配置移动端登录和认证。` | | 配置手机号登录 | `$easystarter-mobile-aliyun-phone-login 配置阿里云手机号登录。` | | 配置存储 | `$easystarter-mobile-storage 配置移动端存储。` | | 创建商店商品 | `$easystarter-mobile-iap-products 创建应用内购买商品。` | | 配置 RevenueCat | `$easystarter-mobile-revenuecat 配置 RevenueCat。` | | 部署 Server | `$easystarter-mobile-deploy-server 部署移动端使用的 Server。` | | 构建并提交 | `$easystarter-mobile-submit-app 构建并提交应用。` | | 修改主题 | `$easystarter-mobile-theme 修改移动端主题。` | | 配置数据分析 | `$easystarter-mobile-analytics 配置移动端数据分析。` | | 配置积分 | `$easystarter-mobile-credits 配置移动端积分系统。` | 通用开发 [#通用开发] | 任务 | 命令 | | --------- | ------------------------------------------- | | 新增 API 路由 | `$easystarter-api-route 为[功能]创建 API 路由。` | | 新增数据表 | `$easystarter-db-schema 为[功能]创建数据库 Schema。` | | 新增翻译 | `$easystarter-i18n 为[功能]添加翻译。` | # 主题系统 (http://page.easystarter.dev/docs/mobile/config/theme) ## 主题系统 App 端的主题由两个独立维度组合而成:**外观模式**(亮色 / 暗色 / 跟随系统)和**主题系列**(Theme Family)。两者组合后生成一个活跃主题名称,由 [Uniwind](https://github.com/mazeincoding/Uniwind) 驱动实际样式渲染。 --- ## 两个维度 ### 外观模式(ThemeModePreference) 控制应用使用亮色还是暗色配色: | 值 | 说明 | | --- | --- | | `system` | 跟随设备系统设置(默认) | | `light` | 固定亮色模式 | | `dark` | 固定暗色模式 | ### 主题系列(ThemeFamily) 控制应用的整体色调风格,内置 4 套: | 值 | 风格 | | --- | --- | | `alpha` | 默认主题系列 | | `lavender` | 薰衣草紫 | | `mint` | 薄荷绿 | | `sky` | 天空蓝 | ### 活跃主题名称 两个维度组合后生成活跃主题名:`{themeFamily}-{resolvedThemeMode}` 例如,用户选择 `lavender` 系列 + 深色模式,则活跃主题为 `lavender-dark`。该名称通过 `Uniwind.setTheme()` 驱动组件库的样式切换。 --- ## 核心文件 | 文件 | 说明 | | --- | --- | | `apps/native/providers/theme-provider.tsx` | 主题状态管理、持久化读写、Uniwind 同步 | | `apps/native/configs/app-config.ts` | 存储键名配置 | --- ## 用户偏好存储 主题选择通过 `AsyncStorage` 持久化到设备本地: | 存储键(来自 `app-config.ts`) | 内容 | | --- | --- | | `{AppName}_theme_preference` | 外观模式:`system` / `light` / `dark` | | `{AppName}_theme_family` | 主题系列:`alpha` / `lavender` / `mint` / `sky` | 其中 `AppName` 来自 `packages/app-config` 中配置的应用名称。 --- ## 更改默认主题 `ThemeProvider` 初始化时,若 `AsyncStorage` 中没有存储值,则使用代码中定义的默认值: ```typescript title="apps/native/providers/theme-provider.tsx" const [themeModePreference, setThemeModePreferenceState] = useState("system"); // 默认:跟随系统 const [themeFamily, setThemeFamilyState] = useState("alpha"); // 默认:alpha ``` 修改这两行的初始值,即可更改新用户首次打开 App 时的默认主题。 --- ## 新增主题系列 ### 在类型中添加新系列 编辑 `theme-provider.tsx`,将新系列加入 `THEME_FAMILIES` 常量和 `ThemeFamily` 类型: ```typescript title="apps/native/providers/theme-provider.tsx" const THEME_FAMILIES = ["alpha", "lavender", "mint", "sky", "ocean"] as const; // ↑ 新增 export type ThemeFamily = "alpha" | "lavender" | "mint" | "sky" | "ocean"; ``` ### 在 Uniwind 中注册对应主题 参照项目中已有的 `alpha`、`lavender` 等系列,新系列需要三步:新建 CSS 文件、在 `global.css` 中导入、在 `metro.config.js` 中注册。 **1. 新建 `apps/native/themes/ocean.css`** 参照 `themes/alpha.css` 的结构,为亮色和暗色各写一套变量: ```css title="apps/native/themes/ocean.css" @layer theme { :root { @variant ocean-light { --radius: 0.5rem; --background: oklch(0.97 0.01 220); --foreground: oklch(0.15 0.03 220); --surface: oklch(0.97 0.01 220); --surface-foreground: var(--foreground); --surface-secondary: oklch(0.93 0.02 220); --surface-secondary-foreground: var(--foreground); --surface-tertiary: oklch(0.90 0.02 220); --surface-tertiary-foreground: var(--foreground); --overlay: oklch(0.97 0.01 220); --overlay-foreground: var(--foreground); --muted: var(--color-neutral-500); --default: oklch(0.92 0.02 220); --default-foreground: oklch(0.15 0.03 220); --accent: oklch(0.45 0.15 220); --accent-foreground: var(--snow); --field-background: var(--default); --field-foreground: var(--foreground); --field-placeholder: var(--muted); --field-border: transparent; --success: oklch(0.55 0.12 154); --success-foreground: var(--snow); --warning: oklch(0.72 0.15 65); --warning-foreground: var(--eclipse); --danger: oklch(0.63 0.19 29); --danger-foreground: var(--snow); --segment: oklch(0.97 0.01 220); --segment-foreground: var(--eclipse); --border: oklch(0.85 0.03 220); --separator: oklch(0.75 0.03 220); --focus: var(--accent); --link: var(--foreground); --surface-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.06); --overlay-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.02), 0 14px 28px 0 rgba(0, 0, 0, 0.03); --field-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.06); } @variant ocean-dark { --radius: 0.5rem; --background: oklch(0.12 0.03 220); --foreground: oklch(0.92 0.02 220); --surface: oklch(0.17 0.03 220); --surface-foreground: var(--foreground); --surface-secondary: oklch(0.22 0.03 220); --surface-secondary-foreground: var(--foreground); --surface-tertiary: oklch(0.25 0.03 220); --surface-tertiary-foreground: var(--foreground); --overlay: oklch(0.20 0.03 220); --overlay-foreground: var(--foreground); --muted: var(--color-neutral-400); --default: oklch(0.20 0.03 220); --default-foreground: var(--snow); --accent: oklch(0.65 0.15 220); --accent-foreground: var(--eclipse); --field-background: var(--default); --field-foreground: var(--foreground); --field-placeholder: var(--muted); --field-border: transparent; --success: oklch(0.55 0.12 154); --success-foreground: var(--snow); --warning: oklch(0.85 0.14 78); --warning-foreground: var(--eclipse); --danger: oklch(0.58 0.16 31); --danger-foreground: var(--snow); --segment: oklch(0.20 0.03 220); --segment-foreground: var(--foreground); --border: oklch(0.25 0.03 220); --separator: oklch(0.35 0.03 220); --focus: var(--accent); --link: var(--foreground); --surface-shadow: 0 0 0 0 transparent inset; --overlay-shadow: 0 0 1px 0 rgba(255, 255, 255, 0.2) inset; --field-shadow: 0 0 0 0 transparent inset; } } } ``` > 每个主题必须声明**完全相同的变量名**,参照 `alpha.css` 核对变量列表。 **2. 在 `global.css` 中导入** ```css title="apps/native/global.css" @import "./themes/alpha.css"; @import "./themes/lavander.css"; @import "./themes/mint.css"; @import "./themes/sky.css"; @import "./themes/ocean.css"; /* 新增 */ ``` **3. 在 `metro.config.js` 中注册** ```js title="apps/native/metro.config.js" module.exports = withUniwindConfig(config, { cssEntryFile: "./global.css", dtsFile: "./uniwind-types.d.ts", extraThemes: [ "alpha-light", "alpha-dark", "lavender-light", "lavender-dark", "mint-light", "mint-dark", "sky-light", "sky-dark", "ocean-light", "ocean-dark", // 新增 ], }); ``` 修改 `metro.config.js` 后需要**重启 Metro**。如遇缓存异常,运行 `npx expo start --clear`。 参考:[Uniwind 自定义主题文档](https://docs.uniwind.dev/theming/custom-themes) ### 添加 i18n 翻译(可选) 在 `packages/i18n/messages/native/` 下的各语言文件中,为新系列补充显示名称和描述: ```json title="packages/i18n/messages/native/zh.json" { "settings": { "themeFamilyOptions": { "ocean": "海洋" }, "themeFamilyDescriptions": { "ocean": "深邃的海洋蓝调" } } } ``` # CLI 创建项目 (http://page.easystarter.dev/docs/mobile/create-project) 购买 EasyStarter 并接受 GitHub collaborator 邀请后,用这条命令创建**你自己的项目**。不用再手动 clone 模板仓库。 模板仓库是私有的。请先接受 collaborator 邀请。如果命令下载模板失败,通常是邀请还没接受。 ### 安装工具 - [`Node.js 22+`](https://nodejs.org/) - [`pnpm 9+`](https://pnpm.io/) - [`git`](https://git-scm.com/) ### 创建项目 在任意空目录执行: ```bash pnpm create easystarter my-app ``` 或: ```bash npx create-easystarter my-app ``` 把 `my-app` 换成你的项目名,必须是小写 kebab-case(例如 `acme-app`,不要写成 `Acme App`)。 这条命令会下载 EasyStarter、按你的名称初始化项目、写入本地环境变量、安装依赖,并可以启动开发服务。 ### 按你的产品选择集成 向导问的是**你的产品**要用哪些能力。现在先选需要的即可,之后还可以在 `packages/app-config` 里改。 | 问题 | 怎么选 | |---|---| | 应用显示名称 | 用户看到的名字,例如 `Acme` | | 登录方式 | 至少选一种:`email-password`、`email-otp`、`github`、`google`、`apple`、`sms` | | Web 支付 | `none`、`stripe`、`creem` 或 `waffo` | | 移动端支付 | `none` 或 `revenuecat` | | 邮件服务 | `cloudflare` 或 `resend` | | 是否启用积分 | 只有要卖积分时才选是 | | 是否安装依赖 | 选是 | | 是否初始化 git | 选是 | 默认是邮箱密码登录、不开支付、邮件用 Cloudflare、不开积分。选完后会迁移本地数据库并启动 Web + Server。 不想逐项回答、直接用默认值: ```bash pnpm create easystarter my-app -y ``` 已经确定技术选型时,也可以直接带参数,例如: ```bash pnpm create easystarter my-app \ --app-name "Acme" \ --auth email-password github \ --payments stripe \ --native-payments revenuecat \ --email resend \ --no-dev ``` `--no-dev` 只创建项目、不启动开发服务。`--auth` 可以传多种登录方式。 ### 打开本地应用 创建完成后(没有传 `--no-dev` 时): | 应用 | 地址 | |---|---| | Web | [http://localhost:3000](http://localhost:3000) | | Server | [http://localhost:3001](http://localhost:3001) | | Extension | [http://localhost:3002](http://localhost:3002) | 如果服务没有自动起来: ```bash cd my-app pnpm dev:web+server ``` 环境变量文件已经生成好了。不要再把 `.example` 文件覆盖上去,否则会冲掉 `BETTER_AUTH_SECRET`。 如果你选了 GitHub、Google、Apple、短信、Resend、Stripe、Creem、Waffo 或 RevenueCat,命令结束时会列出这些功能还要补的密钥。用对应功能前先填上。 ### 连接 Cloudflare `create` 只初始化本地项目,**不会**在 Cloudflare 上创建 D1 / R2,也**不会**部署。 准备绑定你的 Cloudflare 账号时再执行(远程数据库、对象存储、部署都需要这一步): ```bash cd my-app pnpm exec create-easystarter init ``` 或: ```bash npx create-easystarter init ``` 如果还没登录 Cloudflare,会打开浏览器登录,然后创建或复用 `{project}-db` 和 `{project}-bucket`,并把 ID 写回项目。本地开发可以先跑,不必马上执行这一步。 如果要同时做远程 D1 迁移: ```bash pnpm exec create-easystarter init --migrate ``` 远程迁移需要带 D1 编辑权限的 Cloudflare API Token,在 [API Tokens](https://dash.cloudflare.com/profile/api-tokens) 创建。`init` 时可以先跳过 Token,之后把 `CLOUDFLARE_API_TOKEN` 写进环境变量,再执行 `pnpm db:migrate`。 也可以一并写入生产环境地址: ```bash pnpm exec create-easystarter init \ --website-url https://example.com \ --server-url https://api.example.com ``` # 数据库 (http://page.easystarter.dev/docs/mobile/database) ## 数据库 项目基于 [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) 构建数据库层。 ### 创建 D1 数据库 参考官方文档:[D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) 方式一:通过 Cloudflare Dashboard 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. 进入 **Storage & databases → D1 SQL database** 3. 点击 **Create database** 4. 输入数据库名,例如 `easysaas-db` 5. 按需选择地域 6. 点击 **Create** 创建完成后,在数据库详情页复制 `database_id`。 方式二:通过 Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` 命令执行成功后会输出 D1 绑定配置,其中包含 `database_id`。 ### 配置 D1 数据库 ID 拿到 `database_id` 后,需要填入以下两个位置。 环境变量文件: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` `CLOUDFLARE_ACCOUNT_ID` 和 `CLOUDFLARE_API_TOKEN` 的获取方式见 [Cloudflare 集成](/docs/web/integrations/cloudflare)。 把 `database_id` 填到: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` 和: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler 配置: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` 也就是说,`apps/server/wrangler.jsonc` 里的 `database_id` 也要填成同一个值。 ### 执行本地数据库开发命令 本地数据库开发只需要按这个顺序执行: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` 根据 `apps/server/src/db/schema` 生成迁移文件。 `pnpm db:migrate:local` 把刚生成的迁移应用到本地 D1 数据库。这个命令会自动处理本地 D1 初始化。 `pnpm db:studio:local` 打开本地 D1 的可视化界面,检查表结构和数据是否正确。 # 上架 App (http://page.easystarter.dev/docs/mobile/deploy/deploy-app) ## 上架 App EasyStarter 移动端使用 [EAS(Expo Application Services)](https://expo.dev/eas) 进行云端构建和应用商店提交。EAS 负责签名、打包和自动化提交流程,无需在本地配置复杂的证书环境。 **上架前,请确认 [Server 已部署完成](/docs/mobile/deploy/deploy-server),且 RevenueCat 和认证回调已正确配置。** ## 前置准备 | 平台 | 需要 | | --- | --- | | **iOS** | Apple Developer 账号($99/年)、App Store Connect 中已创建 App | | **Android** | Google Play Console 账号($25 一次性费用)、已创建应用 | | **通用** | [EAS CLI](https://docs.expo.dev/eas-build/setup/) 已安装,并已登录 Expo 账号 | ```bash npm install -g eas-cli eas login ``` ## 更新 `eas.json` 配置 在构建之前,将 production profile 的环境变量更新为正式地址: 如果还没有创建本地生产环境变量文件,先复制模板: ```bash cp apps/native/.env.production.example apps/native/.env.production ``` 然后在 `apps/native/.env.production` 中填入同一组生产环境 `EXPO_PUBLIC_` 变量。EAS 云端构建以 `eas.json` 的 `env` 字段为准,`.env.production` 主要用于本地生产构建或导出。 ```jsonc title="apps/native/eas.json" { "build": { "production": { "autoIncrement": true, "channel": "production", "environment": "production", "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", // Server 正式地址 "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", // Web 正式地址 "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } } } } ``` ## 更新 `app.json` 标识符 确认 `apps/native/app.json` 中的 Bundle Identifier 和包名与应用商店账号一致: ```jsonc title="apps/native/app.json" { "expo": { "name": "Your App Name", "slug": "your-app-slug", "version": "1.0.0", "ios": { "bundleIdentifier": "com.yourcompany.yourapp", // App Store Connect 中的 Bundle ID "appleTeamId": "YOUR_TEAM_ID" // 在 Apple Developer 账号中查看 }, "android": { "package": "com.yourcompany.yourapp" // Google Play 中的包名 }, "extra": { "eas": { "projectId": "your-eas-project-id" // 运行 eas init 后自动生成 } } } } ``` 如果还没有初始化 EAS 项目: ```bash cd apps/native eas init ``` ### 构建 iOS 生产包 ```bash pnpm -F native eas:build:ios:production ``` 等价于 `eas build --platform ios --profile production`,在 EAS 云端构建 `.ipa` 文件。 首次构建时,EAS 会引导你: - 自动创建或复用 Apple Distribution Certificate - 自动创建或复用 Provisioning Profile 构建完成后,可在 [Expo Dashboard](https://expo.dev) 中查看构建状态和下载产物。 > 如果需要本地构建(需要 macOS + Xcode),使用 `eas:build:ios:production:local`。 ### 构建 Android 生产包 ```bash pnpm -F native eas:build:android:production ``` 等价于 `eas build --platform android --profile production`,在 EAS 云端构建 `.aab` 文件(Google Play 推荐格式)。 首次构建时,EAS 会提示你上传或自动生成 Android Keystore。**请妥善保管 Keystore 文件**,更新 App 时必须使用相同的 Keystore 签名。 ### 提交到 App Store ```bash pnpm -F native eas:submit:ios:production ``` 等价于 `eas submit --platform ios --profile production`。 EAS 会自动将构建产物上传到 App Store Connect。上传成功后: 1. 登录 [App Store Connect](https://appstoreconnect.apple.com) 2. 进入你的 App → **TestFlight** 验证构建无误 3. 切换到 **App Store** 标签,创建一个新版本 4. 填写版本说明、截图、关键词等元数据 5. 提交审核(通常 1–3 个工作日) ### 提交到 Google Play ```bash pnpm -F native eas:submit:android:production ``` 等价于 `eas submit --platform android --profile production`。 上传成功后: 1. 登录 [Google Play Console](https://play.google.com/console) 2. 进入你的 App → **发布 → 正式版** 3. 查看新上传的构建,填写版本说明 4. 提交审核(通常数小时到数天) ## OTA 更新(无需重新提交审核) EasyStarter 集成了 [Expo Updates](https://docs.expo.dev/eas-update/introduction/),支持向已安装的 App 推送 JS 层更新,无需重新走应用商店审核流程。 适合修复 Bug、调整 UI 或更新文案等不涉及原生代码的变更: ```bash # 推送到 production 渠道 pnpm -F native eas:update:production ``` > OTA 更新只能更新 JavaScript/TypeScript 代码和静态资源,不能更新原生模块(如新增 Expo 插件、修改 `app.json` 的原生字段等)。原生层变更仍需重新走完整构建和提交流程。 ## 版本管理 `eas.json` 中 `"autoIncrement": true` 会在每次构建时自动递增 Build Number(iOS)和 Version Code(Android),无需手动修改 `app.json`。 | 字段 | 说明 | | --- | --- | | `version`(`app.json`) | 对用户可见的版本号,如 `1.2.0`,需手动更新 | | Build Number / Version Code | 商店内部版本号,`autoIncrement` 自动管理 | | `runtimeVersion` | 控制 OTA 兼容性,默认策略为 `appVersion` | # 部署 Server (http://page.easystarter.dev/docs/mobile/deploy/deploy-server) ## 部署 Server(Cloudflare Workers) EasyStarter 的服务端基于 [Hono](https://hono.dev/),运行在 [Cloudflare Workers](https://workers.cloudflare.com/) 上,通过 D1 作为数据库、R2 作为对象存储。 部署前请确认以下前置工作已完成: - Cloudflare 账号凭据已准备好(参见 [Cloudflare 集成](/docs/web/integrations/cloudflare)) - D1 数据库已创建,已获取 **Database ID**(参见 [数据库](/docs/web/integrations/database)) - R2 存储桶已创建,已获取 **存储桶名称**(参见 [对象存储](/docs/web/integrations/storage)) EasyStarter 支持两种部署方式,按需选择: | 方式 | 适合场景 | | --- | --- | | **方式一:本地 CLI 部署** | 快速上线、一次性部署、完全手动控制 | | **方式二:GitHub 自动部署** | 持续交付、团队协作、推送即部署 | --- ## 方式一:本地 CLI 部署 本地登录 Wrangler 后,手动执行部署命令。 ```bash npx wrangler login ``` ## 环境变量说明 Server 端的变量分为三类,分别放在不同位置: | 类型 | 文件 | 说明 | | --- | --- | --- | | **公开配置** | `apps/server/wrangler.jsonc` → `vars` | 非敏感值,明文写入配置,随代码部署 | | **本地开发** | `apps/server/.dev.vars` | 本地 `wrangler dev` 自动加载,不参与部署 | | **生产 Secrets** | `apps/server/.env.production` | 通过 `wrangler secret bulk` 加密推送到 Workers,不参与构建 | > **不要**将 `.env.production` 提交到 Git。`.dev.vars` 也应加入 `.gitignore`。 ### 更新 `apps/server/wrangler.jsonc` 将你的 Worker 名称、D1 Database ID、R2 存储桶名称和公开变量填入配置: ```jsonc title="apps/server/wrangler.jsonc" { "name": "your-server-worker", // Worker 名称,决定默认访问域名,全局唯一 "main": "src/index.ts", "compatibility_date": "2025-06-15", "compatibility_flags": ["nodejs_compat"], "d1_databases": [ { "binding": "DB", "database_name": "your-db-name", // D1 数据库名称(任意,供你参考) "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // D1 Database ID } ], "vars": { "NODE_ENV": "production", "WEBSITE_URL": "https://your-app.com", // Web 端公开访问地址 "SERVER_URL": "https://your-server.workers.dev", // Server Worker 自身地址 "GITHUB_CLIENT_ID": "your-github-client-id", // 公开值,无需保密 "GOOGLE_CLIENT_ID": "your-google-client-id" // 公开值,无需保密 }, "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-bucket-name" // R2 存储桶名称(任意,供你参考) } ] } ``` | 字段 | 说明 | | --- | --- | | `name` | Worker 名称,部署后默认 URL 为 `https://..workers.dev` | | `database_id` | 创建 D1 数据库时获取的 UUID | | `vars.WEBSITE_URL` | Web 应用地址,用于 Better Auth 的回调 URL、邮件跳转链接等 | | `vars.SERVER_URL` | Server Worker 自身地址,用于 Better Auth 配置和 CORS | | `bucket_name` | 与 Cloudflare 后台创建的 R2 存储桶名称保持一致 | ### 准备生产 Secrets(`.env.production`) 先复制生产环境变量模板(如果文件还没有创建): ```bash cp apps/server/.env.production.example apps/server/.env.production ``` 然后在 `apps/server/.env.production` 中填入所有敏感变量。这个文件不参与构建,只用于下一步的 `wrangler secret bulk` 命令。 ```bash title="apps/server/.env.production" // 环境变量示例:(这一部分请参考 apps/server/.env.production.example 中的内容) BETTER_AUTH_SECRET=your-better-auth-secret GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret R2_PUBLIC_URL=https://your-bucket.your-subdomain.r2.dev REVENUECAT_API_KEY=your-revenuecat-api-key STRIPE_SECRET_KEY=your-stripe-secret-key STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret ``` **注意事项:** - 不使用某项集成(如 RevenueCat)时,对应变量可以留空或删除 ### 部署 Worker ```bash pnpm deploy:server ``` 等价于在 `apps/server` 目录下执行 `wrangler deploy`,将源码编译后发布到 Cloudflare Workers。 首次部署成功后,控制台会输出 Worker 的访问地址: ``` Deployed your-server-worker triggers: https://your-server-worker.your-subdomain.workers.dev ``` 记录这个地址,后续配置 Web 端和更新 `SERVER_URL` 时需要用到。 ### 推送 Secrets 将 `.env.production` 中的所有变量批量加密写入 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 等价于 `wrangler secret bulk .env.production`。推送后,变量以加密形式存储在 Cloudflare 侧,不会出现在部署代码或日志中。 > Secrets 推送和代码部署是独立操作。每次更新敏感变量只需重新推送 Secrets,无需重新部署代码。 ### 运行数据库迁移 将数据库 Schema 应用到 Cloudflare D1。 `pnpm db:migrate` 使用 drizzle-kit 的 D1 HTTP 驱动,需要以下三个变量在 `apps/server/.dev.vars` 中已填写: ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= # Cloudflare 账号 ID CLOUDFLARE_API_TOKEN= # 有 D1 Edit 权限的 API Token CLOUDFLARE_D1_DATABASE_ID= # D1 数据库 UUID ``` 确认后执行: ```bash pnpm db:migrate ``` 迁移成功后,D1 中会创建所有必要的表(用户、会话、订阅、账单等)。 > 每次修改数据库 Schema 后,先执行 `pnpm db:generate` 生成迁移文件,再执行 `pnpm db:migrate` 应用到生产 D1。 ## 验证部署 登录 Cloudflare Dashboard → **Workers & Pages**,选择刚部署的 Worker,在 **Logs** 标签下可以实时查看请求日志,确认服务正常响应。 --- ## 方式二:GitHub 自动部署 将 GitHub 仓库与 Cloudflare 绑定后,每次推送到指定分支都会自动触发构建和部署,无需在本地执行任何命令。 ### 连接 GitHub 仓库 1. 进入 [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. 点击 **Create** → **Workers** → **Connect to Git** 3. 授权 Cloudflare 访问你的 GitHub 账户,选择对应仓库 4. 选择部署分支(通常为 `master`) ### 推送 Secrets 在连接仓库后、首次触发构建前,先通过本地 CLI 将所有 Secrets 推送到 Cloudflare,确保 Worker 启动时所有敏感变量已就绪: ```bash pnpm -F server secrets:bulk:production ``` 等价于 `wrangler secret bulk .env.production`,将 `apps/server/.env.production` 中的所有变量加密写入 Worker Secrets。 > Secrets 推送和代码部署是独立操作。后续只有 Secrets 值变更时才需要重新推送,日常代码更新无需重推。 ### 填写构建配置 在 Cloudflare 的构建设置页面填入以下配置: | 项目 | 值 | | --- | --- | | **根目录** | `/` | | **构建命令** | `pnpm --filter server build` | | **部署命令** | `pnpm --filter server run deploy` | | **版本命令** | `pnpm --filter server run deploy` | > 根目录设置为 `/` 是因为这是 monorepo,pnpm workspace 需要从仓库根目录解析依赖。 ### 禁止非生产分支构建 保存构建配置后,**取消勾选非生产分支构建**。 此步骤不可跳过。Server Worker 使用固定名称——如果 Cloudflare 对非生产分支(如 `feature/x`)触发构建并部署,会直接覆盖同一个 Worker,导致生产流量指向未完成的代码,并可能对线上 D1 数据库执行未经验证的迁移操作。 ### 运行数据库迁移 自动部署**不会**自动执行数据库迁移。首次部署完成后,仍需在本地手动运行: ```bash pnpm db:migrate ``` 确保 `apps/server/.dev.vars` 中已填写: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` > 后续每次修改 Schema 时,同样需要在本地执行 `pnpm db:generate` + `pnpm db:migrate`。 配置完成后,每次向目标分支推送代码,Cloudflare 都会自动触发构建并部署最新版本。可在 **Workers & Pages → 你的 Worker → Deployments** 中查看每次部署的状态和日志。 --- ## 自定义域名(推荐) 1. 进入 **Workers & Pages** → 选择你的 Server Worker → **Settings → Domains & Routes** 2. 点击 **Add Custom Domain**,输入已托管在 Cloudflare 的域名,例如 `api.yourdomain.com` 3. 绑定成功后,按以下说明更新相关配置 ### 为什么必须更新这两个字段 `SERVER_URL` 和 `WEBSITE_URL` 不是普通环境变量——它们写在 `wrangler.jsonc` 的 `vars` 块中,会在 `wrangler deploy` 时**编译进 Worker Bundle**,运行时直接读取。改了值之后必须重新部署才能生效。 这两个字段在认证系统中扮演关键角色: | 字段 | 用途 | | --- | --- | | `SERVER_URL` | Better Auth 的 `baseURL`;OAuth 回调地址(`/api/auth/callback/github` 等);Cookie 的 `domain` 和 `secure` 策略 | | `WEBSITE_URL` | Better Auth 的 `trustedOrigins`(CORS 白名单);邮件中的跳转链接 | 如果这两个值与实际域名不匹配,OAuth 登录回调会 404,跨域请求会被 CORS 拦截,会话 Cookie 无法写入。 ### 需要修改的文件 ```jsonc title="apps/server/wrangler.jsonc" "vars": { "WEBSITE_URL": "https://your-app.com", // Web 端正式域名 "SERVER_URL": "https://api.yourdomain.com" // Server 自定义域名(刚绑定的) } ``` Web 端也持有 Server 的地址,用于前端直接调用 API: ```jsonc title="apps/web/wrangler.jsonc" "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com" // 与 SERVER_URL 保持一致 } ``` **OAuth 应用后台** 如果你绑定了新的 `SERVER_URL`,需要同步更新 OAuth 应用(GitHub / Google)的回调地址: - GitHub:**Settings → Developer settings → OAuth Apps** → 更新 **Authorization callback URL** 为 `https://api.yourdomain.com/api/auth/callback/github` - Google:**Google Cloud Console → 凭据 → OAuth 2.0 客户端** → 更新**已授权的重定向 URI** ### 重新部署使配置生效 同时部署 Server 和 Web(因为两边都有改动): ```bash pnpm deploy ``` > `vars` 是随代码打包的静态配置,不是 Secret。每次修改 `wrangler.jsonc` 的 `vars` 都必须重新执行部署,仅推送 Secrets 不会更新这些值。 # 移动端快速开始 (http://page.easystarter.dev/docs/mobile/getting-started) ## 准备 无论是 Web 端还是移动端,先完成工作区级别的初始化: ### 安装必要工具 确保你的开发环境中已安装以下工具: - 安装 [`Node.js 20+`](https://nodejs.org/) - 安装 [`pnpm 9+`](https://pnpm.io/) - 安装 [`git`](https://git-scm.com/) ### 克隆仓库 克隆仓库并进入项目根目录,以便开始开发: ```bash # clone 仓库 git clone https://github.com/sunshineLixun/easystarter.git your-project-name # 进入项目根目录 cd your-project-name # 移除默认的 origin git remote remove origin # 添加你自己的 origin git remote add origin https://github.com/your-username/your-project-name.git # 推送到 origin git push -u origin main ``` ### 安装依赖 执行以下命令安装项目所需的所有依赖包: ```bash pnpm install ``` {props.children} # 移动端总览 (http://page.easystarter.dev/docs/mobile) 移动端 [#移动端] EasyStarter 的移动端基于 React Native 和 Expo 构建,代码位于 `apps/native`。它与 Web 端共享认证逻辑、API 契约、配置和国际化资源,但拥有独立的页面结构、原生能力和支付方式。 移动端使用 RevenueCat 管理 iOS 和 Android 的订阅与内购,通过 Better Auth 实现深链式认证,支持 Apple 原生登录。UI 层基于 HeroUI Native,采用 Tailwind 风格的样式系统(Uniwind)。 技术栈 [#技术栈] | 层 | 技术 | | ----- | ----------------------------------------------------------------------------------------------------------------- | | 框架 | [React Native](https://reactnative.dev/) + [Expo](https://expo.dev/) | | 路由 | [Expo Router](https://docs.expo.dev/router/introduction/)(文件路由) | | UI | [HeroUI Native](https://heroui-native.com/) + [Uniwind](https://uniwind.dev/)(Tailwind v4) | | API | [oRPC](https://orpc.dev/) + [TanStack Query](https://tanstack.com/query) | | 认证 | [Better Auth](https://better-auth.com/) + @better-auth/expo | | 支付 | [RevenueCat](https://www.revenuecat.com/)(iOS/Android 订阅与内购) | | 构建与上架 | [EAS Build](https://docs.expo.dev/build/introduction/) + [EAS Submit](https://docs.expo.dev/submit/introduction/) | | 国际化 | @repo/i18n | 建议阅读顺序 [#建议阅读顺序] **第一次接触这个项目:** 1. [快速开始](/docs/mobile/getting-started) — 本地跑起 Expo 应用 2. [项目结构](/docs/mobile/project-structure) — 了解目录组织和关键文件 **接入外部服务:** 3. [Cloudflare 集成](/docs/mobile/integrations/cloudflare) — 账号凭据,所有服务的基础 4. [数据库](/docs/mobile/integrations/database) — D1 配置 5. [认证服务](/docs/mobile/integrations/authentication) — Better Auth + OAuth 配置 6. [RevenueCat 内购](/docs/mobile/integrations/iap/revenuecat) — 移动端订阅与内购 **准备上线:** 7. [部署 Server](/docs/mobile/deploy/deploy-server) — 服务端先行 8. [上架 App](/docs/mobile/deploy/deploy-app) — EAS 构建 + 应用商店提交 在看 Web 端? [#在看-web-端] 切换到 [Web 文档](/docs/web)。 # 数据分析 (http://page.easystarter.dev/docs/mobile/integrations/analytics) ## 数据分析 EasyStarter 移动端使用 [OpenPanel](https://openpanel.dev/) 作为数据分析方案 —— 开源、隐私友好、支持自托管,未配置 Client ID 或 Client Secret 时自动跳过初始化。 移动端默认不监听每次页面切换。建议只在关键漏斗节点显式埋点,例如首次完成 onboarding、登录成功、订阅开始、核心生成任务完成、提交作品等。这样既能看清转化,也不会把免费额度消耗在低价值的普通导航事件上。 ### 注册 OpenPanel 并创建项目 官方文档:[React Native SDK](https://openpanel.dev/docs/sdks/react-native) 1. 前往 [openpanel.dev](https://openpanel.dev/) 注册账号 2. 在 Dashboard 中点击 **Create Project** 3. 填写 **Project name**(可与 Web 端共用同一个项目,方便跨端漏斗分析) 4. 开启 **App**,关闭暂时不需要的 **Website**、**Backend / API** 5. 点击 **Create project** 6. 创建完成后,在项目的客户端信息中复制 App 对应的 **Client ID** 和 **Client Secret** ### 填入环境变量 **本地开发**(`apps/native/.env.development.local`): 由 `expo start` 在开发态加载,仅对本地 dev server 生效,不进入任何打包产物: ```bash title="apps/native/.env.development.local" EXPO_PUBLIC_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET=xxxxxxxx-client-secret ``` **本地生产构建**(`apps/native/.env.production`): 当你在本地以生产模式构建(例如 `eas build --local --profile production`、`expo prebuild` 后的原生构建,或 `expo export` 生成 OTA bundle)时,Expo 会按 `NODE_ENV=production` 自动加载这个文件。先从 `.env.production.example` 拷贝一份: ```bash cp apps/native/.env.production.example apps/native/.env.production ``` 然后填入对应的 Client ID 和 Client Secret: ```bash title="apps/native/.env.production" EXPO_PUBLIC_OPENPANEL_CLIENT_ID=xxxxxxxx-prod-client-id EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET=xxxxxxxx-prod-client-secret ``` > `.env.production` 已加入 `.gitignore`,不会被提交。当你使用 EAS 云端构建时,**`eas.json` 的 `env` 字段优先级更高,会覆盖文件中的同名变量**——所以云端构建只看下面那份配置即可。 **云端构建**(`apps/native/eas.json` 的 `env` 字段): `eas.json` 中有三个 build profile(`development`、`preview`、`production`),每个 profile 都有独立的 `env`,需要按需各自填入 Client ID 和 Client Secret。如果想区分线上 / 测试数据,建议在 OpenPanel 控制台为不同环境分别创建独立的 Client,避免事件混到一起。 ```jsonc title="apps/native/eas.json" { "build": { "development": { "developmentClient": true, "distribution": "internal", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-dev-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-dev-client-secret" } }, "preview": { "distribution": "internal", "channel": "preview", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-preview-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-preview-client-secret" } }, "production": { "autoIncrement": true, "channel": "production", "env": { // ... "EXPO_PUBLIC_OPENPANEL_CLIENT_ID": "xxxxxxxx-prod-client-id", "EXPO_PUBLIC_OPENPANEL_CLIENT_SECRET": "xxxxxxxx-prod-client-secret" } } } } ``` ### 按需上报关键事件 Native 端提供显式调用的统计 helper。只在关键业务节点调用即可: ```ts import { trackOpenPanelEvent } from "@/lib/analytics/openpanel"; trackOpenPanelEvent("onboarding_completed", { source: "welcome_flow", }); ``` 如果某个 screen 是漏斗的一步,也可以手动记录 screen view: ```ts import { trackOpenPanelScreenView } from "@/lib/analytics/openpanel"; trackOpenPanelScreenView("/paywall"); ``` 不要默认监听所有路由变化。先围绕 3-5 个关键漏斗节点埋点,再根据真实问题补事件。 # 配置 app.config.ts (http://page.easystarter.dev/docs/mobile/integrations/app-json) ## 配置 app.config.ts `apps/native/app.config.ts` 是 Expo 应用的核心配置文件。拿到模板后,**第一步**就应该把其中属于 EasyStarter 的标识信息替换成你自己的。 > 详细字段说明参考 [Expo 官方应用配置文档](https://docs.expo.dev/workflow/configuration/) --- ## 第一步:确定并创建应用标识 在修改 `app.config.ts` 之前,先确定你的应用标识,并在 Apple Developer Portal 完成注册。 ### 确定标识符格式 iOS 和 Android 的标识符都推荐使用**反向域名格式**: ``` com.yourcompany.yourapp ``` iOS 的 `bundleIdentifier` 和 Android 的 `package` 建议保持一致,方便管理。 ### 在 Apple Developer Portal 创建 App ID iOS 的 `bundleIdentifier` 不是随意填写的字符串——它必须先在 Apple Developer Portal 注册为 App ID,才能用于 App Store 发布和 Apple 原生能力(如 Sign In with Apple)。 1. 登录 [Apple Developer Portal → Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. 点击 `+` 新建 → 选择 `App IDs` → 类型选 `App` → Continue 3. 填写 **Description**(你的应用名) 4. 填写 **Bundle ID**(例如 `com.yourcompany.yourapp`),选择 `Explicit` 5. 在 Capabilities 中勾选 **Sign In with Apple** 6. 点击 Continue → Register 注册完成后,将这个 Bundle ID 填入以下两处: ```ts title="apps/native/app.config.ts" const baseConfig: ExpoConfig = { ios: { bundleIdentifier: "com.yourcompany.yourapp", }, }; ``` ```bash title="apps/server/wrangler.jsonc → vars" APPLE_APP_BUNDLE_IDENTIFIER=com.yourcompany.yourapp ``` ### Android 包名 Android 不需要提前注册,你只需要在 `app.config.ts` 中填写一个符合格式的包名即可。在发布到 Google Play 时才需要注册。 --- ## 必须替换的字段 以下字段直接影响应用身份、深链和应用商店发布,**必须在开始开发之前修改**: 以下字段均位于 `app.config.ts` 的 `baseConfig` 对象中。 ### `name` 应用显示名称,会出现在设备桌面和系统设置中。 ```ts name: "My App", ``` ### `slug` Expo 服务中的 URL 标识,全局唯一,只能包含字母、数字和连字符。 ```ts slug: "my-app", ``` ### `scheme` Deep link 协议头,用于 OAuth 回调和应用间跳转。建议与 `slug` 保持一致,避免与其他 App 冲突。 ```ts scheme: "my-app", ``` > 移动端 Better Auth 的 OAuth 回调(Google 登录)依赖这个 scheme。 ### `ios.bundleIdentifier` iOS 应用唯一标识,必须与 Apple Developer Portal 中创建的 App ID 完全一致。 ```ts ios: { bundleIdentifier: "com.yourcompany.yourapp", } ``` > 同时也是 `APPLE_APP_BUNDLE_IDENTIFIER` 环境变量的值。 ### `ios.appleTeamId` 你的 Apple 开发者 Team ID,在 [Apple Developer Portal → Membership](https://developer.apple.com/account) 页面中可以找到。 ```ts ios: { appleTeamId: "XXXXXXXXXX", } ``` ### `android.package` Android 应用包名,必须与 Google Play Console 中注册的包名一致,格式为反向域名。 ```ts android: { package: "com.yourcompany.yourapp", } ``` ### `extra.eas.projectId` 和 `updates.url` 这两个字段用于绑定你在 EAS 上的项目。如果你直接克隆了模板,需要先运行: ```bash cd apps/native eas init ``` `app.config.ts` 是动态配置,EAS CLI 不会自动回写。运行完成后,将命令输出的项目 ID 手动填入 `easProjectId`;`updates.url` 会由该常量生成。 --- ## 图标与启动页 以下路径指向的图片需要替换为你自己的品牌资产: | 字段 | 路径 | 说明 | |------|------|------| | `icon` | `./assets/images/icon.png` | 通用图标(1024×1024 PNG) | | `ios.icon.light` | `./assets/images/icon.png` | iOS 浅色模式图标 | | `ios.icon.dark` | `./assets/images/icon.png` | iOS 深色模式图标 | | `android.adaptiveIcon.foregroundImage` | `./assets/images/android-icon-foreground.png` | Android 自适应图标前景 | | `android.adaptiveIcon.backgroundImage` | `./assets/images/android-icon-background.png` | Android 自适应图标背景 | | `android.adaptiveIcon.monochromeImage` | `./assets/images/android-icon-monochrome.png` | Android 单色图标 | | `plugins[expo-splash-screen].image` | `./assets/images/icon.png` | 启动屏图片 | --- ## 完整配置参考 ```ts title="apps/native/app.config.ts" import type { ExpoConfig } from "expo/config"; const appVersion = "1.0.0"; const easProjectId = "your-eas-project-id"; const baseConfig: ExpoConfig = { name: "My App", slug: "my-app", version: appVersion, orientation: "portrait", icon: "./assets/images/icon.png", scheme: "my-app", userInterfaceStyle: "automatic", ios: { appleTeamId: "YOUR_TEAM_ID", buildNumber: "1.0.0", bundleIdentifier: "com.yourcompany.yourapp", usesAppleSignIn: true, icon: { dark: "./assets/images/icon.png", light: "./assets/images/icon.png", }, infoPlist: { ITSAppUsesNonExemptEncryption: false, }, }, android: { adaptiveIcon: { backgroundColor: "#E6F4FE", foregroundImage: "./assets/images/android-icon-foreground.png", backgroundImage: "./assets/images/android-icon-background.png", monochromeImage: "./assets/images/android-icon-monochrome.png", }, predictiveBackGestureEnabled: false, permissions: ["android.permission.RECORD_AUDIO"], package: "com.yourcompany.yourapp", }, extra: { router: {}, eas: { projectId: easProjectId, }, }, updates: { url: `https://u.expo.dev/${easProjectId}`, }, }; export default (): ExpoConfig => ({ ...baseConfig, runtimeVersion: appVersion, }); ``` # 阿里云手机号登录(适合中国大陆业务) (http://page.easystarter.dev/docs/mobile/integrations/authentication/aliyun-phone-auth) ## 阿里云手机号登录 EasyStarter 已经内置基于 [Better Auth phone-number plugin](https://www.better-auth.com/docs/plugins/phone-number) 的手机号登录。服务端通过阿里云号码认证服务 Dypnsapi 发送和校验短信验证码,客户端继续使用 Better Auth 的 `phoneNumber.sendOtp` 与 `phoneNumber.verify`,不需要额外新增自定义认证接口。 如果你的产品主要在国内部署,建议把手机号登录作为首选甚至唯一登录方式。国内用户对手机号验证码登录的接受度最高,GitHub / Google / Apple 这类 OAuth 登录在国内访问稳定性、账号覆盖率和合规配置上都更麻烦;邮箱密码登录也可以保留,但不是必须。也就是说,面向国内场景时,只配置阿里云手机号登录即可,其它登录方式都可以不启用、不申请、不写环境变量。 当前内置流程只支持中国大陆手机号: | 项目 | 当前配置 | | --- | --- | | 手机号格式 | `+86` E.164 格式,例如 `+8613800138000` | | 发送接口 | `SendSmsVerifyCode` | | 校验接口 | `CheckSmsVerifyCode` | | 服务端 Provider | `apps/server/src/sms/providers/aliyun.ts` | | Better Auth 配置 | `apps/server/src/lib/auth.ts` | ## 所需环境变量 ```bash ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= ``` 这两个值是服务端调用阿里云 OpenAPI 的长期访问凭证。不要提交到 Git,也不要放到前端环境变量中。 如果你只保留手机号登录,`GITHUB_CLIENT_ID`、`GITHUB_CLIENT_SECRET`、`GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET` 等 OAuth 变量可以不配置。生产环境只需要保留 Better Auth 会话所需的基础变量和这里的阿里云 AccessKey。 ### 开通号码认证服务 先确认阿里云账号已经开通号码认证服务,并且账号可调用 Dypnsapi 的短信认证接口。 官方接口文档:[SendSmsVerifyCode](https://api.aliyun.com/document/Dypnsapi/2017-05-25/SendSmsVerifyCode) 阿里云文档中说明,`SendSmsVerifyCode` 是号码认证服务的短信验证码发送接口。它使用 Dypnsapi 产品下的 `2017-05-25` API 版本,并且授权 Action 为 `dypns:SendSmsVerifyCode`。 ### 创建 RAM 用户并授权 推荐使用 RAM 用户的 AccessKey,不要直接使用阿里云主账号 AccessKey。 1. 登录 [阿里云 RAM 控制台](https://ram.console.aliyun.com/) 2. 进入 **身份管理** → **用户** 3. 点击 **创建用户** 4. 填写必要的信息 5. 在访问配置中选择 **使用永久 AccessKey 访问** 6. 创建完成后,页面会自动回到用户列表页面 ### 获取 AccessKey ID 和 AccessKey Secret 1. 在用户列表找到刚创建的 RAM 用户,AccessKey 这一列会显示AccessKey ID、AccessKey Secret,点击复制 `AccessKey ID`、`AccessKey Secret` 只会在创建时显示一次,后续无法再次查看。如果丢失,只能禁用旧密钥并重新创建新的 AccessKey。 ### 为 RAM 用户授权调用 Dypnsapi 的权限 1. 在用户列表找到刚创建的 RAM 用户,点击`登录名称 / 显示名称`进入用户详情页 2. 点击 **权限管理** → **新增授权** 3. 在 **权限策略** 步骤,搜索框中搜索 `dypns`,找到 **AliyunDypnsReadOnlyAccess**、 **AliyunDypnsFullAccess** 权限,点击选择 你也可以选择 **PowerUserAccess** 权限,这种权限 提供对阿里云服务和资源的完全访问权限,包含了短信、OSS等等服务的全部权限。为了最小权限原则,建议只授权 **AliyunDypnsReadOnlyAccess**、 **AliyunDypnsFullAccess**,它包含了号码认证服务的全部权限,但不涉及其它服务。 4. 确认授权 ### 填入本地与生产环境变量 本地开发写入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` 生产部署写入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` `.env.production` 只用于通过 Wrangler 批量推送到 Cloudflare Workers Secrets,不参与前端构建,也不应该提交到仓库。 ### 推送生产 Secrets 部署到 Cloudflare Workers 前,把生产密钥推送到 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 推送成功后,Worker 运行时可以通过 `env.ALIBABA_CLOUD_ACCESS_KEY_ID` 和 `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET` 读取密钥。更新密钥时重新执行这条命令即可,不需要因为 Secret 变化而重新部署代码。 ### 保持阿里云验证码参数不变 EasyStarter 内置 Provider 已经按阿里云 `SendSmsVerifyCode` 文档锁定以下参数: ```ts title="apps/server/src/sms/providers/aliyun.ts" const ALIYUN_SMS_VERSION = "2017-05-25"; const ALIYUN_SMS_SIGN_NAME = "速通互联验证码"; const ALIYUN_SMS_TEMPLATE_CODE = "100001"; ``` 这三个值不要修改: | 常量 | 对应阿里云参数 | 为什么不能改 | | --- | --- | --- | | `ALIYUN_SMS_VERSION` | OpenAPI 版本 | Dypnsapi 的 `SendSmsVerifyCode` 接口版本就是 `2017-05-25` | | `ALIYUN_SMS_SIGN_NAME` | `SignName` | 文档示例使用号码认证服务赠送签名 `速通互联验证码`,该接口暂不支持普通自定义签名 | | `ALIYUN_SMS_TEMPLATE_CODE` | `TemplateCode` | 赠送签名必须搭配赠送模板,当前模板 Code 为 `100001` | 这里使用的是号码认证服务 Dypnsapi 的短信认证接口,不是普通短信服务 Dysmsapi 的 `SendSms`。不要把普通短信服务里申请的 `SMS_...` 模板码替换到这里。 ### 本地验证手机号登录 启动服务端与客户端后,在登录页选择手机号登录: ```bash pnpm dev:server pnpm dev:web ``` 输入中国大陆手机号后,前端会调用: ```bash POST /api/auth/phone-number/send-otp ``` 提交验证码时会调用: ```bash POST /api/auth/phone-number/verify ``` 服务端会把 `+86` 号码拆成阿里云需要的 `CountryCode=86` 和本地手机号,然后由阿里云生成、发送并校验验证码。 ## 常见问题 ### 为什么不自己生成验证码? 当前实现使用 `TemplateParam={"code":"##code##","min":"5"}`,让阿里云生成验证码。这样后续校验可以继续调用 `CheckSmsVerifyCode`,服务端不需要自己保存验证码。 ### 为什么不能换成自己的短信签名? `SendSmsVerifyCode` 属于号码认证服务。阿里云文档说明,赠送签名必须搭配赠送模板使用,并且暂不支持使用自定义签名。当前内置值与官方文档示例保持一致。 ### AccessKey 泄露怎么办? 立即在 RAM 控制台禁用或删除泄露的 AccessKey,重新创建新的 AccessKey,并重新推送 `apps/server/.env.production` 到 Workers Secrets。 # 邮箱 OTP 登录 (http://page.easystarter.dev/docs/mobile/integrations/authentication/email-otp) ## 邮箱 OTP 登录 EasyStarter 内置了基于 [Better Auth Email OTP 插件](https://www.better-auth.com/docs/plugins/email-otp) 的邮箱验证码登录功能。用户只需输入邮箱,即可收到一次性验证码完成登录,无需设置密码。 ### 工作流程 1. 用户在登录页输入邮箱地址 2. 服务端通过 [邮件服务](/docs/web/integrations/email) 发送一次性验证码到该邮箱 3. 用户输入收到的验证码 4. 服务端验证通过后完成登录(如用户不存在则自动注册) ### 启用邮箱 OTP 登录 邮箱 OTP 登录通过 `packages/app-config/src/app-config.ts` 中的配置开关控制: ```ts title="packages/app-config/src/app-config.ts" auth: { methods: { emailOtpEnabled: true, }, } ``` ### 前置条件 邮箱 OTP 登录依赖邮件发送能力,请确保已完成 [邮件服务](/docs/web/integrations/email) 配置。 ### OTP 参数配置 验证码的行为参数在 `packages/app-config/src/app-config.ts` 的 `auth.otp.email` 中统一配置: ```ts title="packages/app-config/src/app-config.ts" auth: { otp: { email: { // 验证码位数 otpLength: 6, // 验证码有效期(秒) expiresInSeconds: 300, // 单个验证码最大尝试次数 allowedAttempts: 3, // 客户端重发冷却时间(秒) resendCooldownSeconds: 60, }, }, } ``` ### 速率限制 服务端对邮箱 OTP 相关接口配置了独立的速率限制,防止滥用: ```ts title="apps/server/src/lib/auth.ts" rateLimit: { customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 }, "/sign-in/email-otp": { window: 60, max: 10 }, }, } ``` - 发送验证码:每 60 秒最多 3 次 - 验证登录:每 60 秒最多 10 次 # 邮箱密码登录 (http://page.easystarter.dev/docs/mobile/integrations/authentication) ## 邮箱密码登录 EasyStarter 移动端使用 [Better Auth](https://better-auth.com/) 作为认证方案,内置了邮箱 + 密码登录。 服务端配置位于 `apps/server/src/lib/auth.ts`。 ## 所需环境变量 ```bash BETTER_AUTH_SECRET= ``` ### 获取 `BETTER_AUTH_SECRET` `BETTER_AUTH_SECRET` 用于 Better Auth 签名和加密会话数据,必须是一个足够长的随机字符串。 ```bash openssl rand -base64 32 ``` 复制生成结果,分别填到本地和生产环境: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ### 填入环境变量 本地开发统一放到 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` 生产部署时,敏感值放到 `apps/server/.env.production`: ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ## 邮箱密码认证功能 移动端的 `authClient` 配置在 `apps/native/lib/auth/auth.client.ts`,通过 `@better-auth/expo` 适配器处理 deep link 回调和 cookie 存储。 当前已支持: - 邮箱密码注册和登录 - 基于 Cookie 的跨端会话管理 # 社媒登录 (http://page.easystarter.dev/docs/mobile/integrations/authentication/social-login) ## 社媒登录 EasyStarter 移动端内置了以下社媒登录方式: - Google OAuth 登录 - Apple 原生登录(仅 iOS) 服务端配置位于 `apps/server/src/lib/auth.ts`。Apple 登录使用原生 ID Token 流程:App 调用系统级 Apple 登录弹窗,拿到 `identityToken` 后直接传给 Better Auth 验证,无需网页跳转。 ## 所需环境变量 ```bash GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= APPLE_APP_BUNDLE_IDENTIFIER= ``` ### 创建 Google OAuth Client Google Cloud 控制台:[Google Cloud Console](https://console.cloud.google.com/apis/credentials) 1. 登录 Google Cloud Console,选择或新建项目 2. 进入 `APIs & Services > Credentials` 3. 点击 `Create Credentials` → 选择 `OAuth client ID` 4. 如需要,先完成 `OAuth consent screen` 配置 5. 应用类型选择 `Web application` 6. 填写回调地址 关键字段: - `Authorized JavaScript origins`:你的前端地址,例如 `https://yourdomain.com` - `Authorized redirect URIs`:`{SERVER_URL}/api/auth/callback/google` 移动端 Google OAuth 需要 HTTPS 回调地址,推荐用 [ngrok](https://ngrok.com/) 将本地服务端映射为 HTTPS。 **为什么移动端必须用 ngrok?** 移动端的 Google 登录走的是 **深链(Deep Link)回调流程**,而不是浏览器页面跳转: 1. App 调用 `expo-web-browser` 打开 Google 登录页 2. 用户完成登录后,Google 将请求重定向到服务端回调地址(`SERVER_URL/api/auth/callback/google`) 3. 服务端处理完成后,将用户重定向回 App 的 Deep Link(例如 `myapp://callback`) 4. 操作系统拦截这个 Deep Link,唤起 App,完成登录 这个流程有两个限制: - **Google OAuth 强制要求 HTTPS**,`http://localhost` 不被接受 - 真机和模拟器无法直接访问开发机的 `localhost`——服务端必须有一个从外部可达的地址 **确保 Deep Link Scheme 一致** 上面第 3 步中,服务端会将用户重定向回 App 的 Deep Link(例如 `myapp://callback`)。这个 scheme 需要在两个地方保持一致: - `packages/app-config/src/app-config.ts` 中的 `nativeScheme` 字段 ```ts title="packages/app-config/src/app-config.ts" nativeScheme: "myapp" ``` - `apps/native/app.json` 中的 `scheme` 字段 ```json title="apps/native/app.json" { "expo": { "scheme": "myapp" } } ``` 两者必须相同,否则 Google OAuth 回调后操作系统无法正确唤起 App。 ngrok 解决了这两个问题:它将本地的 `localhost:3001` 暴露为一个公网可访问的 HTTPS 地址,Google 可以成功回调,服务端也能把用户导回 App 的 Deep Link。 **安装 ngrok** 前往 [ngrok 官网](https://ngrok.com/download) 下载并安装,或使用 Homebrew: ```bash brew install ngrok ``` 安装后注册账号并完成认证: ```bash ngrok config add-authtoken YOUR_AUTH_TOKEN ``` Auth Token 在 [ngrok Dashboard → Your Authtoken](https://dashboard.ngrok.com/get-started/your-authtoken) 中获取。 **启动隧道** 在启动本地服务端(`pnpm dev:server`)之后,再开一个终端运行: ```bash ngrok http 3001 ``` ngrok 会输出一个 HTTPS 地址,类似: ``` Forwarding https://xxxx-xxxx.ngrok-free.app -> http://localhost:3001 ``` 将这个地址作为 Google OAuth 回调填写: ``` https://xxxx-xxxx.ngrok-free.app/api/auth/callback/google ``` 同时将 `.dev.vars` 中的 `SERVER_URL` 也改为这个 ngrok 地址,确保 Better Auth 的 baseURL 和 OAuth 回调一致。 > **注意**:免费版 ngrok 每次重启都会生成新地址,需要同步更新 Google Cloud Console 的回调 URI 和本地 `.dev.vars`。 创建完成后你会拿到: - `Client ID` → 对应 `GOOGLE_CLIENT_ID` - `Client Secret` → 对应 `GOOGLE_CLIENT_SECRET` ### 配置 Apple 原生登录 移动端 Apple 登录走的是 **iOS 系统级原生流程**,App 调用 `expo-apple-authentication` 获取 Identity Token,服务端用 Bundle ID(即 `APPLE_APP_BUNDLE_IDENTIFIER`)作为 audience 验证 token。 参考:[Better Auth Apple 文档](https://better-auth.com/docs/authentication/apple) **为 App ID 启用 Sign In with Apple** 在 [配置 app.json](/docs/mobile/integrations/app-json) 一章中已创建好 App ID,现在为它开启 Apple 登录能力: 1. 登录 [Apple Developer Portal → Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. 找到并点击你的 App ID 3. 在 Capabilities 中勾选 `Sign In with Apple` 4. 点击 Continue → Save **创建并配置 Service ID** 1. 在 `Identifiers` 中点击 `+`,选择 `Service IDs` → Continue 2. 填写 Description 和 Identifier(例如 `com.yourcompany.yourapp.si`),这个值作为 `APPLE_CLIENT_ID` 3. 点击 Register 完成创建 4. 在列表中点击刚创建的 Service ID → 勾选 `Sign In with Apple` → 点击 `Configure` 5. 在 **Primary App ID** 中选择你的 App ID 6. 配置 **Domains and Subdomains** 和 **Return URLs**: | 环境 | Domains and Subdomains | Return URLs | |------|------------------------|-------------| | 开发 | `xxxx-xxxx.ngrok-free.app` | `https://xxxx-xxxx.ngrok-free.app/api/auth/callback/apple` | | 生产 | `server.yourdomain.com` | `https://server.yourdomain.com/api/auth/callback/apple` | > 开发环境使用 ngrok 地址,每次 ngrok 重启后需要同步更新这里的 Domain 和 Return URL。生产环境填写你的实际服务端域名。 7. 点击 Next → Done → Continue → Save ### 填入环境变量 本地开发统一放到 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret APPLE_APP_BUNDLE_IDENTIFIER=com.yourcompany.yourapp ``` 生产部署时,敏感值放到 `apps/server/.env.production`: ```bash title="apps/server/.env.production" GOOGLE_CLIENT_SECRET=your-google-client-secret ``` 将非敏感的 ID 类变量填到 `apps/server/wrangler.jsonc` 的 `vars` 中: ```json title="apps/server/wrangler.jsonc" "vars": { "GOOGLE_CLIENT_ID": "your-google-client-id", "APPLE_APP_BUNDLE_IDENTIFIER": "com.yourcompany.yourapp" } ``` ## 当前支持的社媒登录 - Google OAuth(通过 deep link 回调) - Apple 原生登录(通过 ID Token,仅 iOS) Apple 登录仅在真机和 TestFlight 环境中可用,模拟器不支持。 # Cloudflare (http://page.easystarter.dev/docs/mobile/integrations/cloudflare) ## Cloudflare 集成 EasyStarter 的服务端运行在 Cloudflare 体系上,核心会用到: - Cloudflare Workers - Cloudflare D1 - Cloudflare R2 如果你要执行数据库迁移、部署服务端,或者配置对象存储,通常都需要先准备 Cloudflare 相关凭据。 ## 所需环境变量 ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` - `CLOUDFLARE_ACCOUNT_ID`:Cloudflare 账号 ID - `CLOUDFLARE_API_TOKEN`:访问 Cloudflare API 的令牌 这些值通常用于 `apps/server/drizzle.config.ts`,让 `drizzle-kit` 通过 D1 HTTP 驱动执行数据库命令。 ## 获取 `CLOUDFLARE_ACCOUNT_ID` 官方文档:[Find account and zone IDs](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/) ### 方式一:从 Account Home 获取 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `Account Home`。 3. 找到你的账号那一行。 4. 点击右侧菜单按钮。 5. 选择 `Copy account ID`。 复制出来的值就是 `CLOUDFLARE_ACCOUNT_ID`。 ### 方式二:从 Workers & Pages 获取 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `Workers & Pages`。 3. 在 `Account details` 区域找到 `Account ID`。 4. 点击复制。 ## 获取 `CLOUDFLARE_API_TOKEN` 官方文档:[Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) 推荐使用 API Token,不要使用旧的 Global API Key。 ### 创建步骤 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `My Profile > API Tokens`。 3. 点击 `Create Token`。 4. 选择 `Custom token`。 5. 给 token 起一个清晰的名字,比如 `easystarter-d1-migrate`。 6. 在权限里添加: - `Account` -> `D1` -> `Edit` - `Account` -> `Workers R2 Storage` -> `Edit` - `Account` -> `Workers Scripts` -> `Edit` 7. 在资源范围里,只选择当前项目所在的账号。 8. 点击 `Continue to summary`。 9. 检查权限和资源范围。 10. 点击 `Create Token`。 11. 复制生成出来的 token。 复制出来的值就是 `CLOUDFLARE_API_TOKEN`。 ### 注意 - token 只会在创建成功时展示一次 - 丢了就只能重新生成,不能回看明文 - 这个值是敏感信息,只放到 `.dev.vars`、`.env.production` 或 CI secrets 中 ## 放入位置 这些环境变量要位于 `apps/server` 目录下,并命名为 `.dev.vars` 或 `.env.production`。 ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` ```bash title="apps/server/.env.production" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` # 积分系统 (http://page.easystarter.dev/docs/mobile/integrations/credits) ## 积分系统 EasyStarter 内置一套由服务端账本支撑的积分系统。在 App 端,用户通过 RevenueCat 应用内购买积分包: - **售卖积分包**:走 RevenueCat(iOS / Android) - **注册赠送积分**,可选过期时间 - **防薅羊毛**:注册赠送需邮箱验证,并按邮箱、IP、User-Agent 限流 - **按功能消耗积分**,幂等且并发安全 - **开箱即用的 UI**:余额、购买页、流水记录 账本是唯一数据源。客户端永远不会在本地发放积分——只有 RevenueCat webhook(购买入账)和服务端消耗会写入账本。 > 同时也在做 Web?积分共用同一套服务端和配置——Web 端的配置见 [Web · 积分系统](/docs/web/integrations/credits)。 ## 架构 | 层 | 位置 | | --- | --- | | 配置 | `packages/app-config/src/app-config.ts` | | 服务端 | `apps/server/src/credits/*` | | API 路由 | `apps/server/src/routers/common/credits.ts` | | App UI | `apps/native/app/(tabs)/(profile)/credits*.tsx`、`apps/native/hooks/use-credits.ts` | | 购买 | `apps/native/lib/payments/revenuecat.ts` | 积分相关数据表: | 表 | 用途 | | --- | --- | | `credit_account` | 用户当前余额和累计值 | | `credit_transaction` | 不可变流水;`(sourceProvider, sourceType, sourceId)` 元组即幂等键 | | `credit_order` | App 购买订单生命周期 | | `credit_signup_grant_claim` | 注册赠送的资格与防滥用校验 | ## 1. 配置积分包 所有配置都在 `packages/app-config/src/app-config.ts`。 ### 开启 App 积分 ```ts title="packages/app-config/src/app-config.ts" native: { credits: { enabled: true, signupGrant: creditSignupGrant, packages: nativeCreditPackages, }, }, ``` ### 配置注册赠送 新用户首次读取余额时赠送积分。`expiresInDays: null`(或省略)表示永不过期。 ```ts title="packages/app-config/src/app-config.ts" const creditSignupGrant = { enabled: true, amount: 100, // 注册赠送的积分数 expiresInDays: 30, // null = 永不过期 } satisfies NonNullable; ``` ### 创建消耗型产品 积分包是**消耗型**(Consumable)应用内购买产品——可以重复购买,并由账本"消耗"掉。这和永久解锁(Non-Consumable)不同。 **iOS —— App Store Connect** 1. **Monetization → In-App Purchases → +**。 2. 选择 **Consumable(消耗型)**。 3. 填写 **Reference Name**(如 `100 Credits`)和 **Product ID**(如 `com.yourapp.credits.starter`)。 4. 添加价格和本地化信息,点 **Save**。 **Android —— Google Play Console** 1. **Monetize → In-app products → Create product**。 2. 填写 **Product ID**(如 `credits_starter`)、名称、描述和默认价格。 3. **Save** 后点 **Activate**(未激活的产品 RevenueCat 看不到)。 **RevenueCat** 1. **Product catalog → Products → Import**,选中这些消耗型产品并 **Import**。 2. 积分只需做到这一步——**不需要 Offering 或 Entitlement**。App 通过产品 ID 直接拉取(`Purchases.getProducts(..., NON_SUBSCRIPTION)`),服务端收到 `NON_RENEWING_PURCHASE` webhook 后按产品 id 匹配发放积分。Offering 和 Entitlement 只用于订阅和永久解锁。 > RevenueCat 的完整配置(App 配置、商店凭证、webhook)见 [RevenueCat](/docs/mobile/integrations/iap/revenuecat) 和 [商店产品](/docs/mobile/integrations/iap/store-products) 文档。 ### 配置 App 积分包 把上一步的产品 id 填进每个平台的 `native`。`providerProductId` 必须与商店产品 id 完全一致。 ```ts title="packages/app-config/src/app-config.ts" const nativeCreditPackages = [ { id: "starter", // 内部积分包 id amount: 100, // 购买后到账的积分数 native: { ios: { provider: "revenuecat", providerProductId: "easystarter_credits_starter_ios", currency: "usd", amountCents: 499, status: "active", }, android: { provider: "revenuecat", providerProductId: "easystarter_credits_starter_android", currency: "usd", amountCents: 499, status: "active", }, }, }, ] satisfies AppCreditsConfig["packages"]; ``` ### 补充积分包文案 为每个积分包 `id` 添加标题和描述,购买页才能正确渲染。 ```jsonc title="packages/i18n/src/messages/native/zh.json" "credits": { "packages": { "starter": { "title": "入门积分包", "description": "{count} 积分,适合轻量使用。" } } } ``` **配置规则** - `amount` 与 `amountCents` 必须是正整数。 - `providerProductId` 必填、唯一,且必须与 RevenueCat 产品 id 一致。 - 在 RevenueCat 中将积分产品配置为**非订阅**(消耗型)产品。 - 用 `status: "archived"` 可隐藏积分包而不删除历史记录。 - 若同一个积分包也在 Web 售卖,复用相同的 `id`(`amount` 和 `status` 须一致)——见 [Web · 积分系统](/docs/web/integrations/credits)。 ## 2. 准备服务端 ### 执行迁移 ```bash pnpm db:migrate:local # 本地 D1 pnpm db:migrate # 远程 D1 ``` ### 配置 RevenueCat webhook 密钥 积分复用 RevenueCat 集成,除了 [应用内购买](/docs/mobile/integrations/iap/revenuecat) 已要求的密钥外,无需额外配置: | 支付商 | 密钥 | | --- | --- | | RevenueCat | `REVENUECAT_WEBHOOK_SECRET` | ### 确认维护定时任务 `apps/server/src/index.ts` 会按 `apps/server/wrangler.jsonc` 中的每日计划运行 `runCreditMaintenance`,用于过期赠送积分、清理过期的待支付订单。 ```jsonc title="apps/server/wrangler.jsonc" "triggers": { "crons": ["10 16 * * *"] } ``` ## 3. 消耗积分 消耗积分是你需要接入到自己业务里的部分。优先在**服务端路由**中调用,避免客户端绕过余额校验。 ```ts title="服务端路由" await context.credits.consumeCredits({ user: { userId: context.session.user.id }, amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` 从 App 则使用 `useCredits` hook: ```ts const credits = useCredits(); await credits.consume({ amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` > `idempotencyKey` 必须对应一次真实的消耗事件(8–120 字符)。用相同 key 重试只会返回当前余额,不会重复扣费。消耗时优先扣最快过期的积分;余额不足时抛出 `Insufficient credits`。 ## 账本规则 - 注册赠送积分在首次读取余额 / 流水或消耗时懒发放。需邮箱已验证,并按邮箱、IP、User-Agent 限流。 - 购买的积分永不过期(`expiresAt = null`)。 - 赠送积分按 `expiresInDays` 过期,由每日定时任务清理。 - 消耗时优先扣最快过期的积分,再扣永久付费积分。 - 退款只回收原购买记录中尚未消耗的剩余额度。 # 数据库 (http://page.easystarter.dev/docs/mobile/integrations/database) ## 数据库 项目基于 [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) 构建数据库层。 ### 创建 D1 数据库 参考官方文档:[D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) 方式一:通过 Cloudflare Dashboard 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. 进入 **Storage & databases → D1 SQL database** 3. 点击 **Create database** 4. 输入数据库名,例如 `easysaas-db` 5. 按需选择地域 6. 点击 **Create** 创建完成后,在数据库详情页复制 `database_id`。 方式二:通过 Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` 命令执行成功后会输出 D1 绑定配置,其中包含 `database_id`。 ### 配置 D1 数据库 ID 拿到 `database_id` 后,需要填入以下两个位置。 环境变量文件: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` `CLOUDFLARE_ACCOUNT_ID` 和 `CLOUDFLARE_API_TOKEN` 的获取方式见 [Cloudflare 集成](/docs/web/integrations/cloudflare)。 把 `database_id` 填到: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` 和: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler 配置: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` 也就是说,`apps/server/wrangler.jsonc` 里的 `database_id` 也要填成同一个值。 ### 执行本地数据库开发命令 本地数据库开发只需要按这个顺序执行: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` 根据 `apps/server/src/db/schema` 生成迁移文件。 `pnpm db:migrate:local` 把刚生成的迁移应用到本地 D1 数据库。这个命令会自动处理本地 D1 初始化。 `pnpm db:studio:local` 打开本地 D1 的可视化界面,检查表结构和数据是否正确。 # Cloudflare 邮件服务 (http://page.easystarter.dev/docs/mobile/integrations/email) ## Cloudflare 邮件服务 Cloudflare Email Service 适合已经把域名托管在 Cloudflare 的项目。配置完成后,用户可以在自己的邮箱中收到注册验证、密码重置和邮箱验证码邮件。 Cloudflare 邮件服务不需要额外的邮件 API Key,也不需要新增邮件环境变量。 ## 选择 Cloudflare 邮件服务 在 `packages/app-config/src/app-config.ts` 中将邮件服务商切换为 `cloudflare`,并将 `yourdomain.com` 替换为你自己的发件域名: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "cloudflare", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ## 本地开发接收邮件 本地开发时,邮件 HTML 会记录到 Wrangler 日志和临时目录。打开项目下的 `.wrangler/tmp/email` 目录,找到临时 HTML 文件并点击打开,即可查看邮件内容。 ## 线上开启邮件发送 ### 开通 Email Sending 1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/) 2. 进入 **Compute → Email Service → Email Sending** 3. 点击 **Onboard Domain**,选择你的发件域名 4. **验证并激活** 如果域名本身就在 Cloudflare 管理,所需记录通常可以直接在控制台中完成配置。向任意真实用户邮箱发送邮件需要开通 [Workers Paid 套餐](https://developers.cloudflare.com/email-service/platform/pricing/)。 ### 部署并收取测试邮件 正常部署服务端即可。线上 Worker 会直接连接真实的 Cloudflare 邮件服务。 部署完成后,使用一个真实邮箱触发注册验证、忘记密码或邮箱验证码。收到邮件即表示线上邮件发送已经生效;如果暂时没有看到,请检查垃圾邮件目录,并在 Cloudflare Email Sending 页面查看活动日志。 # Resend 邮件服务 (http://page.easystarter.dev/docs/mobile/integrations/email/resend) ## Resend 邮件服务 Resend 通过 API Key 发送邮件。配置完成后,用户可以在自己的邮箱中收到注册验证、密码重置和邮箱验证码邮件。 ## 线上开启邮件发送 ### 注册 Resend 并获取 API Key 1. 前往 [resend.com](https://resend.com/) 注册账号 2. 登录后进入 [API Keys](https://resend.com/api-keys) 页面 3. 点击 **Create API Key** 4. 权限选择 **Sending access** 5. 创建后立即复制 API Key API Key 以 `re_` 开头,并且只会展示一次,请妥善保存。 ### 验证发件人域名 1. 在 Resend 中进入 **Domains** 页面 2. 点击 **Add Domain**,填写你的发件域名 3. 按页面提示把 DNS 记录添加到域名服务商 4. 回到 Resend,点击 **Verify DNS Records** 5. 等待域名状态验证通过 验证通过后,可以使用 `noreply@yourdomain.com` 这样的地址发送邮件。 ### 填写线上配置 把 `RESEND_API_KEY` 填入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 然后在 `packages/app-config/src/app-config.ts` 中选择 Resend,并填写已经验证的域名: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "resend", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### 部署并收取测试邮件 推送生产环境变量并正常部署服务端。部署完成后,使用一个真实邮箱触发注册验证、忘记密码或邮箱验证码。 收到邮件即表示线上邮件发送已经生效;如果暂时没有看到,请检查垃圾邮件目录和 Resend Logs。 ## 本地调试并收到真实邮件 把同一个 `RESEND_API_KEY` 填入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 确认邮件服务商为 `resend`,重启本地服务端,然后触发注册验证、忘记密码或邮箱验证码。邮件会直接发送到你填写的测试邮箱。 Resend 测试阶段可以先向自己的 Resend 注册邮箱发送邮件;向其他用户发送前,需要先完成发件域名验证。如果没有收到,请检查垃圾邮件目录和 Resend Logs。 # RevenueCat 内购 (http://page.easystarter.dev/docs/mobile/integrations/iap/revenuecat) ## 接入 RevenueCat 内购 EasyStarter 移动端使用 [RevenueCat](https://www.revenuecat.com/) 管理 iOS 和 Android 的订阅与内购。RevenueCat 负责与 App Store / Google Play 对接,并通过 Webhook 将订阅状态同步到服务端。 ## 前置准备 在开始之前,请确保以下账号和配置已就绪: - [RevenueCat 账号](https://app.revenuecat.com/signup)(免费开始) - **iOS**:Apple Developer 账号,App Store Connect 中已创建 App 内购买项目 - **Android**:Google Play Console 账号,已创建订阅产品 ## 前置:在商店后台创建产品 在配置 RevenueCat 之前,需要先在 App Store Connect 和 Google Play Console 中创建订阅和内购产品,并记录每个产品的 **产品 ID**。 详细步骤请参阅 [创建内购产品](/docs/mobile/integrations/iap/store-products) 章节。 ## 在 RevenueCat 后台配置 注册并登录 [RevenueCat](https://app.revenuecat.com) 后,按以下步骤完成后台配置。 ### 1. 创建 App 进入 **Project Settings → Apps** 页面,点击 **New app configuration**,选择平台(iOS 选 App Store,Android 选 Google Play)。 - **iOS**:填写 App 名称和 Bundle ID,然后按照官方文档完成以下两项凭据配置: - [In-app purchase key configuration](https://www.revenuecat.com/docs/service-credentials/itunesconnect-app-specific-shared-secret/in-app-purchase-key-configuration) - [App Store Connect API key configuration](https://www.revenuecat.com/docs/service-credentials/itunesconnect-app-specific-shared-secret/app-store-connect-api-key-configuration) - **Android**:填写 App 名称和 Package Name,按照官方文档配置 [Google Play service credentials](https://www.revenuecat.com/docs/service-credentials/creating-play-service-credentials)。 配置完成后点击 **Save changes**。 ### 2. 导入 Products Products 是 RevenueCat 对 App Store / Google Play 中具体商品的映射。 进入 **Product catalog → Products**,点击右上角 **Import** 按钮。RevenueCat 会自动拉取您在商店后台已创建的产品列表,勾选所有需要的产品(包括月度订阅、年度订阅、终身买断等),点击 **Import** 完成导入。 如果 Import 后列表为空,说明商店凭据尚未生效或产品尚未在商店后台审核通过。iOS 产品需要先创建好 In-app purchase 并处于"Ready to Submit"及以上状态才能被拉取到。 导入完成后,每个 Product 会显示其 **Product Identifier**(即 App Store / Google Play 中的产品 ID)。请记录这些 ID,后续在 `app-config.ts` 中会用到。 ### 3. 配置 Offerings Offerings 定义了向用户展示的购买方案组合,每个 Offering 下可包含多个 Package(对应不同时长或类型的产品)。 1. 进入 **Product catalog → Offerings**,RevenueCat 默认会有一个名为 **default** 的 Offering。 2. 点击 **default** 进入详情页,点击 **Add Package**,依次添加以下三个 Package: - **Monthly**:Package Type 选 `Monthly`,关联对应的月度订阅 Product - **Yearly**:Package Type 选 `Annual`,关联对应的年度订阅 Product - **Lifetime**:Package Type 选 `Lifetime`,关联对应的终身买断 Product 3. 每个 Package 添加后,在右侧面板中选择对应的 Product(iOS 和 Android 分别选择),然后点击 **Save**。 Offerings 决定了 App 内显示哪些购买方案。您可以创建多个 Offering 用于 A/B 测试,但 SDK 默认使用 `default` Offering。 ### 4. 配置 Entitlements Entitlements 定义了用户购买后获得的权益(即解锁哪些功能)。通常一个 App 只需要一个 Entitlement,例如 `pro`。 1. 进入 **Product catalog → Entitlements**,点击右上角 **New** 按钮,填写 **Identifier**(例如 `pro`),点击 **Save** 创建。 2. 进入该 Entitlement 的详情页,点击 **Attach**,在弹出的产品列表中勾选所有需要授予此权益的 Products(Monthly、Yearly、Lifetime 三个都勾选),点击 **Attach** 保存。 Entitlement Identifier 需要与 `eas.json` 中的 `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` 保持一致。如果您使用 `pro`,则环境变量也填 `pro`。 Entitlement 配置完成后,当用户购买任意一个已关联的 Product 时,RevenueCat 会自动将该 Entitlement 标记为 active,App 端通过 SDK 即可判断用户是否有权限。 ### 5. 配置付费墙(可选) RevenueCat 内置了可视化付费墙编辑器,支持无需发版即可远程修改内购页面。 进入 **Paywalls** 页面,点击 **New paywall**,从内置模板中选择一个样式开始编辑: - 选择要绑定的 Offering(选 `default`) - 填写付费墙名称 - 配置隐私协议和服务条款的 URL 根据 App Store 审核指南,内购页面**必须**包含隐私协议和服务条款的链接,否则可能被拒审。 如果所选模板没有 Lifetime Package 的位置,可以复制一个现有 Package 区块,将其类型改为 `Lifetime`,并修改对应的展示文案。编辑完成后点击 **Publish** 发布,付费墙即可在 App 中生效。 更多付费墙变量和自定义选项参考 [RevenueCat 官方文档](https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/variables)。 ### 6. 获取 API Key 和 Entitlement ID **获取 SDK API Key**:进入 **Project Settings → API Keys**,找到对应平台的 Public SDK key(iOS 以 `appl_` 开头,Android 以 `goog_` 开头),点击 **Show Key → Copy**,分别填入 `eas.json` 的 `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` 和 `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY`。 **获取 Entitlement ID**:进入 **Product catalog → Entitlements**,复制您创建的 Entitlement 的 **Identifier**,填入 `eas.json` 的 `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID`。 ### 7. 配置 Webhook(同步订阅状态到服务端) RevenueCat 通过 Webhook 将订阅事件(购买、续费、取消等)实时推送到 Server。EasyStarter 的服务端已内置处理逻辑,只需配置好 Webhook URL 即可。 #### 开发环境(ngrok) 本地开发时 Server 运行在 `localhost`,RevenueCat 无法直接访问。需要使用 [ngrok](https://ngrok.com) 将本地端口暴露到公网。 1. 安装 ngrok(如果尚未安装): ```bash brew install ngrok ``` 2. 启动本地 Server: ```bash pnpm dev:server ``` 默认监听 `http://localhost:3001`。 3. 开启 ngrok 隧道: ```bash ngrok http 3001 ``` ngrok 会输出一个公网 URL,例如: ``` Forwarding https://a1b2-123-456-789.ngrok-free.app -> http://localhost:3001 ``` 4. 在 RevenueCat Dashboard → **Project Settings → Integrations → Webhooks** → **Add webhook**,Webhook URL 填写: ``` https://a1b2-123-456-789.ngrok-free.app/api/webhooks/revenuecat ``` 5. 在 **Authorization header** 字段中填入你自己生成的随机密钥。推荐使用完整的 Bearer Token 格式: ```bash # 先生成一个随机值 openssl rand -hex 32 ``` 将生成的值拼上 `Bearer ` 前缀,例如 `Bearer a1b2c3d4...`,填入 RevenueCat Webhook 配置的 **Authorization header** 字段。 然后把**同一个完整值**填入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" REVENUECAT_WEBHOOK_SECRET=Bearer your_random_secret ``` `REVENUECAT_WEBHOOK_SECRET` 是你自己生成并配置的值,而不是 RevenueCat 颁发的密钥。RevenueCat 每次调用 Webhook 时会将这个值作为 `Authorization` 请求头原样带过来,服务端再做比对验证。建议直接写完整的 `Bearer xxx` 形式,最清晰且不易配错。 ngrok 免费版每次重启隧道 URL 会变化,需要同步更新 RevenueCat 后台的 Webhook URL。如果频繁调试,可以注册 ngrok 账号使用固定域名。 #### 生产环境 部署到 Cloudflare Workers 后,Server 拥有固定的公网地址,直接配置即可。 1. 在 RevenueCat Dashboard → **Project Settings → Integrations → Webhooks** → **Add webhook**,Webhook URL 填写: ``` https://your-server.workers.dev/api/webhooks/revenuecat ``` 2. 在 **Authorization header** 字段中填入你自己生成的随机密钥(与开发环境使用相同的格式): ```bash openssl rand -hex 32 ``` 将生成值拼上 `Bearer ` 前缀,例如 `Bearer a1b2c3d4...`,填入 RevenueCat Webhook 配置的 **Authorization header** 字段,同时将**同一个完整值**填入 `apps/server/.env.production`(或通过 `wrangler secret` 管理): ```bash title="apps/server/.env.production" REVENUECAT_WEBHOOK_SECRET=Bearer your_random_secret ``` 3. 推送 Secret 到 Cloudflare: ```bash pnpm -F server secrets:bulk:production ``` ## 更新 `eas.json` 环境变量 将获取的 API Keys 和 Entitlement ID 填入 `apps/native/eas.json` 中各 profile 的 `env` 块: ```jsonc title="apps/native/eas.json" { "build": { "development": { "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } }, "preview": { "env": { // ... 同上,可使用相同或独立的 RevenueCat 项目 } }, "production": { "env": { "EXPO_PUBLIC_SERVER_API_URL": "https://your-server.workers.dev", "EXPO_PUBLIC_WEB_APP_URL": "https://your-app.com", "EXPO_PUBLIC_REVENUECAT_IOS_API_KEY": "appl_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY": "goog_xxxxxxxxxxxxxxxx", "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID": "pro" } } } } ``` > `EXPO_PUBLIC_` 前缀的变量会被 Expo 注入到客户端代码中,不要将私密信息放在这里。RevenueCat SDK Key 是公开的客户端凭据,放在此处是安全的。 ## 更新 `app-config.ts` 定价配置 `packages/app-config/src/app-config.ts` 中的 `native.payments` 定义了 App 内的定价方案,需要与 RevenueCat 后台和 App Store / Google Play 的产品 ID 保持一致: ```ts title="packages/app-config/src/app-config.ts" native: { payments: { enabled: true, provider: "revenuecat", ios: { plans: [ { id: "pro", prices: [ { id: "monthly", provider: "revenuecat", providerPriceId: "easystarternative_10_1m", // App Store Connect 中的产品 ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, { id: "yearly", provider: "revenuecat", providerPriceId: "easystarternative_100_1y", currency: "usd", amountCents: 10000, priceType: "subscription", interval: "year", status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "revenuecat", providerPriceId: "easystarternative_299_lifetime", currency: "usd", amountCents: 29900, priceType: "lifetime", status: "active", }, ], }, ], }, android: { plans: [ { id: "pro", prices: [ { id: "monthly", provider: "revenuecat", providerPriceId: "pro_monthly_android", // Google Play Console 中的产品 ID currency: "usd", amountCents: 800, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, }, ``` | 字段 | 说明 | | --- | --- | | `providerPriceId` | 对应 App Store Connect / Google Play Console 中的产品 ID,必须完全一致 | | `amountCents` | 用于 App 内展示价格,单位为分($10.00 = 1000) | | `priceType` | `subscription`(订阅)或 `lifetime`(一次性购买) | | `status` | `active` 为上线,`inactive` 为下架 | ## Server 端变量 Server 端只需要上面 Webhook 步骤中配置的 `REVENUECAT_WEBHOOK_SECRET`,用于校验 RevenueCat 推送到 `/api/webhooks/revenuecat` 的请求。不需要额外配置 RevenueCat Secret API Key。 # 创建内购产品 (http://page.easystarter.dev/docs/mobile/integrations/iap/store-products) ## 创建应用内购买产品 在配置 RevenueCat 之前,需要先在 App Store Connect 和 Google Play Console 中创建产品,并记录每个产品的 **产品 ID**。这些 ID 后续会填入 RevenueCat 后台和 `app-config.ts`。 --- ## iOS:App Store Connect ### 准备工作 - 已在 App Store Connect 中创建应用(App 必须存在才能添加内购产品) - Bundle ID 与 `app.json` 中的 `ios.bundleIdentifier` 一致 ### 首先新建 App 登录 [App Store Connect](https://appstoreconnect.apple.com/apps),点击顶部的 **Apps**,再点击左上角的 **+** 按钮选择 **New App**。 在弹窗中填写以下信息: - **Platform**:选择 iOS - **App Name**:您的 App 名称 - **Primary Language**:选择主要语言 - **Bundle ID**:下拉选择与 `app.json` 中 `ios.bundleIdentifier` 对应的 Bundle ID - **SKU**:仅对您自己可见,可填写 App 名称或任意标识符 - **User Access**:选择 Full Access 填写完成后点击 **Create**。 ### 创建订阅组 续费订阅必须归属于一个订阅组,所以需要先创建订阅组。 1. 在 App 详情页左侧菜单中点击 **Subscriptions**,然后点击右上角 **Create** 按钮新建订阅组。 2. 填写订阅组名称(常见命名:`Pro`、`Plus`、`Premium`、`Unlimited`),点击 **Create**。 3. 进入订阅组详情页后,向下滚动找到 **Localizations**,点击 **+** 添加本地化信息: - 选择语言(先添加英文,再添加中文) - 填写订阅组名称(即订阅组在 App Store 中展示给用户的名称) - **App Name Display Options** 建议选择 **Use Custom Name**,避免 App 名称修改后影响展示 - 点击 **Create** 保存 订阅组国际化后,App Store 会根据用户所在地区自动显示对应语言的订阅组名称。 ### 在订阅组下创建订阅 EasyStarter 默认支持年度订阅、月度订阅和终身买断三种方案。以下以年度订阅为例,月度订阅步骤相同。 #### 创建年度订阅 **1. 新建订阅** 在订阅组详情页,点击 **Subscriptions** 区域的 **+** 按钮,进入新建订阅表单,填写: - **Reference Name**:用于 App Store Connect 和销售报告内部显示,不对用户展示。建议使用简明描述,不超过 64 个字符,例如 `Annual Pro`。 - **Product ID**:唯一的字母数字标识符,创建后不可修改,即使删除产品也不能复用。建议遵循以下命名规范: ``` __ ``` 例如,年费 $69 的年度订阅:`easystarternative_69_1y` 点击 **Create** 进入订阅详情页。 **2. 设置订阅时长和可售地区** 在订阅详情页,找到 **Subscription Duration**,选择 **1 Year**。 继续向下找到 **Availability**,勾选所有国家 / 地区(建议全选以覆盖更多用户),点击 **Save**。 **3. 设置价格** 滚动到 **Subscription Prices** 区域,点击 **Add Subscription Prices**。 在弹窗中: - **Country or Region**:选择 **United States**(以美元作为基准货币) - **Price**:选择目标价格(例如 $69.99),点击 **See Additional Prices** 可查看更多价格档位 选好后点击 **Next**,App Store Connect 会根据美元汇率自动换算出其他地区的建议价格。您可以在此单独修改各地区价格,例如将中国大陆设置为 ¥128。确认无误后点击 **Next → Confirm**。 **4. 添加本地化信息** 滚动到 **Localization** 区域,点击 **Add Localization**,依次添加英文和中文: - 英文:Display Name `Annual Subscription`,Description `Unlimited access to all features` - 中文(简体):Display Name `年度订阅`,Description `解锁所有高级功能` 每条填写完成后点击 **Add**。 **5. 上传审核截图** 滚动到 **Review Information** 区域: - **Screenshot**:上传一张 640×920 像素的付费墙截图,供 Apple 审核人员参考。测试阶段可先上传任意占位图,提交审核前需替换为实际付费墙截图。 - **Review Notes**:可选,向审核人员补充说明。 全部填写完成后,点击右上角 **Save**。年度订阅创建完成。 #### 创建月度订阅 回到订阅组详情页,点击 **Subscriptions** 区域的 **+** 按钮,按照[创建年度订阅](#创建年度订阅)完全相同的步骤操作,注意: - **Subscription Duration** 选择 **1 Month** - Product ID 命名示例:`easystarternative_10_1m` 同理,您也可以按需创建周订阅、季度订阅或半年订阅。 **Product ID 创建后不可修改**,即使删除产品后该 ID 也永久占用,无法在其他应用中复用。请在创建前仔细规划命名方案。 ### 创建一次性购买(终身会员) 一次性购买不归属订阅组,需要单独创建。回到 App 详情页,在左侧菜单找到 **In-App Purchases**,点击右上角 **+** 或 **Create**。 在弹窗中填写: - **Type**:选择 **Non-Consumable**(非消耗型) **非消耗型**:用户购买后永久解锁,没有时间和次数限制(适合终身会员)。 **消耗型**:用户购买一定数量的使用机会,用完需再次购买(适合 AI 调用次数等场景)。 - **Reference Name**:内部名称,例如 `Lifetime Pro` - **Product ID**:命名示例 `easystarternative_299_lifetime` 点击 **Create** 后进入详情页,按照年度订阅相同的步骤设置 **Availability**、**Price Schedule**、**Localization** 和 **Review Information**,最后点击 **Save**。 🎉 至此,App Store Connect 中的所有订阅和一次性购买创建完成。 --- ## Android:Google Play Console ### 准备工作 - 已在 Google Play Console 中创建应用 - 已完成账单资料配置(国家 / 银行信息) - 应用至少已上传过一次 APK / AAB(内购功能需要应用版本存在) ### 创建订阅(月度 / 年度) Google Play 的订阅产品由**订阅(Subscription)→ 基础方案(Base Plan)→ 优惠(Offer)**三层组成: ### 新建订阅 1. 进入 [Google Play Console](https://play.google.com/console) → 选择你的 App 2. 左侧菜单点击 **Monetize → Subscriptions** 3. 点击 **Create subscription** 4. 填写: - **Product ID**:唯一标识符(例如 `pro_monthly`) > **产品 ID 命名建议**:全小写,仅含字母、数字和下划线,一旦创建不可修改。 - **Name**:展示给用户(例如 `Pro 月度订阅`) - **Description**:简短说明 5. 点击 **Save** ### 添加基础方案(Base Plan) 1. 在刚创建的订阅下,点击 **Add base plan** 2. 配置: - **Base plan ID**:例如 `monthly-base` - **Billing period**:选择 **Monthly**(月度)或 **Yearly**(年度) - **Price**:填写价格(例如 $9.99) - **Free trial**:可选,例如 7 天免费试用 3. 点击 **Save & publish base plan** > 一个订阅产品可以有多个基础方案,例如月度和年度可以在同一个订阅 ID 下,也可以分开创建两个订阅。EasyStarter 建议**各周期单独创建订阅**,对应 RevenueCat 更清晰。 ### 激活订阅 1. 基础方案保存后,状态为 **Inactive** 2. 点击 **Activate** 激活(必须激活后 RevenueCat 才能识别) 3. 按同样步骤创建年度订阅(Product ID 例如 `pro_yearly`) ### 创建一次性购买(Lifetime) 一次性购买使用**应用内商品(In-app products)**: ### 新建应用内商品 1. 左侧菜单点击 **Monetize → In-app products** 2. 点击 **Create product** 3. 填写: - **Product ID**:例如 `pro_lifetime` - **Name**:例如 `Pro 终身授权` - **Description**:简短说明 - **Default price**:例如 $29.99 4. 点击 **Save** ### 激活商品 1. 产品保存后状态为 **Inactive** 2. 点击 **Activate** 激活 --- ## 产品 ID 对应关系总结 创建完成后,记录所有产品 ID,后续会在 RevenueCat 和 `app-config.ts` 中使用: | 产品 | iOS Product ID | Android Product ID | | --- | --- | --- | | 月度订阅 | `com.yourcompany.yourapp.pro.monthly` | `pro_monthly` | | 年度订阅 | `com.yourcompany.yourapp.pro.yearly` | `pro_yearly` | | 终身授权 | `com.yourcompany.yourapp.pro.lifetime` | `pro_lifetime` | > 两个平台的产品 ID 可以不同,只需在 `app-config.ts` 的 `ios` 和 `android` 配置块中分别填写即可。 # 推送通知 (http://page.easystarter.dev/docs/mobile/integrations/notifications) ## 推送通知 EasyStarter 默认关闭 App 通知。原因是 iOS 和 Android 都需要先配置各自的推送凭证,未配置时直接开启会导致真机打包或签名失败。 通知功能最好使用真机测试。模拟器很容易收不到推送通知,即使配置正确,也可能无法正常完成测试。 ## 1. 开启通知 打开 `packages/app-config/src/app-config.ts`,找到 `notifications`,把 `enabled` 改为 `true`: ```ts title="packages/app-config/src/app-config.ts" notifications: { enabled: true, provider: "expo", }, ``` 保存后,从项目根目录执行: ```bash pnpm -F native prebuild ``` 这个命令会在现有 iOS 和 Android 工程中同步最新配置,并把通知所需的原生配置写进去。 ## 2. 配置 iOS APNs(iOS App 需要设置这个) 推荐同时参考 Expo 官方文档: - [Expo Push Notifications Setup](https://docs.expo.dev/push-notifications/push-notifications-setup/) - [Expo iOS Credentials](https://docs.expo.dev/app-signing/app-credentials/) ### 在 Apple Developer 开启推送 1. 打开 [Apple Developer Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. 找到与你的 `ios.bundleIdentifier` 一致的 App ID 3. 进入 App ID 4. 勾选 **Push Notifications** 5. 点击 **Save** 模板默认 Bundle ID 是 `native.easystarter.dev`。如果你已经改成自己的 Bundle ID,请选择自己的 App ID。 ### 配置 APNs Key 先在 Apple Developer 创建 APNs Key: 1. 打开 [Apple Developer Keys](https://developer.apple.com/account/resources/authkeys/list) 2. 点击 **+** 创建新的 Key 3. 填写 Key 名称,并勾选 **Apple Push Notifications service (APNs)** 4. 点击 **Continue → Register** 5. 下载 `.p8` 文件,并保存页面显示的 **Key ID** 6. 获取 **Apple Team ID**:登录 [Apple Developer Account](https://developer.apple.com/account/),打开 **Membership details**,复制页面中的 **Team ID**(由 Apple 分配的 10 位字符串) `.p8` 文件只能在 Apple Developer 创建时下载一次,请妥善保存,不要提交到 Git。 **方法一:使用 EAS CLI** 进入 Native 目录并运行: ```bash cd apps/native pnpm dlx eas-cli credentials ``` 运行后按下面操作: 1. 选择 **iOS** 2. 选择要使用的构建环境,例如 **development** 3. 如果提示登录 Apple Developer,请登录并选择正确的 Team 4. 选择 **Push Notifications: Manage your Apple Push Notifications Key** 5. 选择 **Set up your project to use Push Notifications** 6. 如果提示使用已有的 Push Key,请选择 **[Add a new push key]** 7. 当出现 **Generate a new Apple Push Notifications service key?** 时选择 **No** 8. 在 **Path to P8 file** 中填写刚才下载的 `.p8` 文件路径 9. 填写凭证信息: - **Key ID**:创建 APNs Key 成功后,复制 Apple Developer 页面显示的 **Key ID**。如果已经关闭该页面,可以重新打开 [Apple Developer Keys](https://developer.apple.com/account/resources/authkeys/list),选择刚才创建的 Key 后查看 - **Apple Team ID**:[前往 Apple Developer Account 获取](https://developer.apple.com/account/) 完成后,EAS CLI 会将这个 Push Key 配置到当前项目。 **方法二:通过 Expo 网页配置** 1. 登录 [Expo Dashboard](https://expo.dev/) 2. 进入 **Credentials → Android & iOS credentials → Apple Push Keys** 3. 上传 `.p8` 文件 **完成配置后重新构建 iOS App** 无论使用方法一还是方法二,配置完成后都需要重新构建 App。将 iPhone 连接到电脑并在手机上选择信任此电脑,然后在项目根目录运行: ```bash pnpm -F native dev:ios-device ``` 按照提示选择你的 iPhone。命令执行完成后,新构建的 App 会自动安装到手机上。 **iOS Provisioning Profile 缺少推送权限** 如果构建时提示 Provisioning Profile 缺少 Push Notifications 或 `aps-environment`,按下面操作: 1. 找到 `apps/native/ios/EasyStarterNative.xcworkspace`,使用 Xcode 打开该文件 2. 在 Xcode 选择 **EasyStarterNative Target** 3. 打开 **Signing & Capabilities** 4. 开启 **Automatically manage signing** 5. 确认 **Team** 正确。模板默认 Team 是 `8622M955TV`,使用自己的 Apple 账号时请改成自己的 Team 完成后,等待 Xcode 自动刷新 Provisioning Profile。 ## 3. 配置 Android FCM V1(安卓 App 需要设置这个) 完整操作请参考 Expo 官方文档:[FCM V1 Credentials](https://docs.expo.dev/push-notifications/fcm-credentials/)。 ### 创建 Firebase Android App 1. 打开 [Firebase Console](https://console.firebase.google.com/) 2. 创建或选择一个 Firebase 项目 3. 添加 Android App 4. Package Name 填写 `apps/native/app.config.ts` 中的 `android.package` 5. 下载 `google-services.json` 6. 将文件放到: ```text apps/native/google-services.json ``` 7. 在 `apps/native/app.config.ts` 的 `android` 配置中添加: ```ts title="apps/native/app.config.ts" android: { googleServicesFile: "./google-services.json", }, ``` ### 创建 FCM V1 凭证 1. 在 Firebase 打开 **Project settings → Service accounts** 2. 点击 **Generate new private key** 3. 下载 Service Account JSON 文件 4. 进入 `apps/native` 并运行: ```bash pnpm dlx eas-cli credentials ``` 5. 依次选择: - **Android** - **production** - **Google Service Account** - **Manage your Google Service Account Key for Push Notifications (FCM V1)** - **Upload a new service account key** 6. 上传刚才下载的 Service Account JSON Service Account JSON 包含私钥,不要提交到 Git。 ### 重新生成并构建 Android App 回到项目根目录执行: ```bash pnpm -F native prebuild pnpm -F native dev:android-device ``` 安装新构建的 App 后再测试通知。 ## 4. 配置 Expo Access Token EasyStarter 发送通知时需要 Expo Access Token。 1. 登录 [Expo Dashboard](https://expo.dev/) 2. 进入 **Credentials → Access tokens → Personal access tokens** 3. 点击 **Create token** 4. 填写 Token Name,例如 `easystarter-push-production`,创建后复制 Access Token 5. 写入本地 Server 环境变量: ```bash title="apps/server/.dev.vars" EXPO_ACCESS_TOKEN=your-expo-access-token ``` 6. 写入生产 Server 环境变量: ```bash title="apps/server/.env.production" EXPO_ACCESS_TOKEN=your-expo-access-token ``` 7. 部署生产环境前上传 Secret: ```bash pnpm -F server secrets:bulk:production ``` 不要把 Access Token 写到 `EXPO_PUBLIC_*` 变量或 App 代码中。 如果还没有执行最新数据库迁移,运行: ```bash pnpm db:migrate:local pnpm db:migrate ``` ## 5. 在 App 中测试通知 真机无法通过 `localhost` 访问电脑上的 Server,因此真机测试必须使用 ngrok。如果已经配置并正在运行 ngrok,可以跳过下面的 ngrok 配置步骤。 先启动 ngrok: ```bash ngrok http 3001 ``` 复制 ngrok 显示的 HTTPS 地址,例如 `https://abc123.ngrok-free.app`,并修改以下两个文件: ```bash title="apps/server/.dev.vars" SERVER_URL=https://abc123.ngrok-free.app ``` ```bash title="apps/native/.env.development.local" EXPO_PUBLIC_SERVER_API_URL=https://abc123.ngrok-free.app ``` 两个地址必须保持一致。免费 ngrok 地址发生变化后,也需要同时更新这两个文件。 保持 ngrok 运行,然后在项目根目录启动 Server: ```bash pnpm -F server dev ``` 再打开一个终端,在项目根目录运行 iOS 真机 App: ```bash pnpm -F native dev:ios-device ``` 或运行 Android 真机 App: ```bash pnpm -F native dev:android-device ``` 然后在 App 中: 1. 登录账号 2. 打开 **设置 → 通知** 3. 点击 **开启系统通知** 4. 允许系统通知权限 5. 点击 **发送测试通知** 测试按钮只会向当前安装的 App 发送一条测试消息。 App 在前台时会显示 App 内提示;要测试系统通知栏,请先把 App 切到后台再发送。 > 请使用重新构建的 Development Build 或 Release Build 测试,不要使用 Expo Go。 ## 6. 使用 Expo 网站测试 也可以打开 [Expo Push Notifications Tool](https://expo.dev/notifications) 发送测试通知。 ### Recipent 填写当前设备的 Expo Push Token,格式类似: ```text ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx] ``` 获取方式: 1. 先在 App 中登录并允许通知 2. 本地环境运行 `pnpm db:studio:local`,生产环境运行 `pnpm db:studio` 3. 打开 `notification_subscription` 表 4. 找到 `provider` 为 `expo` 的当前设备记录 5. 复制 `provider_subscription_id` ### Access Token 填写前面创建的 Expo Access Token,也就是 `EXPO_ACCESS_TOKEN` 的值。 ### Data (JSON string) 可以填写: ```json {"kind":"self_test","destination":{"type":"notification_settings"}} ``` 点击通知后会打开 App 的通知设置页。 如果只想测试通知是否能显示,也可以留空。填写时必须使用合法 JSON,字段和文本都要使用双引号。 其他常用字段: | 字段 | 填写内容 | | --- | --- | | Message title | 测试通知标题 | | Message body | 测试通知内容 | | Channel ID | Android 填 `default` | | Sound name | 填 `default` | ## 使用其他推送服务商 EasyStarter 的通知发送层基于 `NotificationProvider` 接口设计,可以扩展 OneSignal、Braze、Customer.io、CleverTap 等其他推送服务商。下面以 OneSignal 为例。 ### 第一步:扩展服务商类型 打开 `packages/app-config/src/types.ts`,将新服务商追加到 `SUPPORTED_NOTIFICATION_PROVIDERS`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_NOTIFICATION_PROVIDERS = ["expo", "onesignal"] as const; ``` ### 第二步:接入 App 端 SDK 按照服务商提供的 Expo 或 React Native 文档安装 SDK 和 Expo Plugin,并完成 iOS APNs、Android FCM 配置。 然后修改 `apps/native/hooks/use-notification-subscription-sync.ts`,将 Expo Push Token 替换为新服务商返回的设备推送标识,并将其注册到 Server。 不同服务商的设备推送标识不能混用。切换服务商后,用户需要打开新版本 App,让设备重新注册。 ### 第三步:实现 Provider 在 `apps/server/src/notifications/providers/` 下新建服务商文件,并实现 `NotificationProvider` 接口: ```text apps/server/src/notifications/providers/onesignal.ts ``` Provider 需要负责: - 使用服务商 API 批量发送通知 - 将发送结果转换为成功或失败状态 - 查询并返回通知送达结果 - 设置服务商允许的单次发送和查询数量 可以参考现有的 `apps/server/src/notifications/providers/expo.ts`。 ### 第四步:注册服务商 打开 `apps/server/src/notifications/provider.ts`,导入新 Provider,并在 `switch` 中添加对应分支: ```ts title="apps/server/src/notifications/provider.ts" import { createOneSignalNotificationProvider } from "./providers/onesignal"; // Add this branch to the existing switch. case "onesignal": return createOneSignalNotificationProvider({ appId: env.ONESIGNAL_APP_ID, apiKey: env.ONESIGNAL_API_KEY, }); ``` 同时将服务商需要的密钥写入 Server 环境变量。 ### 第五步:切换配置 打开 `packages/app-config/src/app-config.ts`,将 `notifications.provider` 改为新服务商: ```ts title="packages/app-config/src/app-config.ts" notifications: { enabled: true, provider: "onesignal", }, ``` 然后在项目根目录执行: ```bash pnpm -F native prebuild pnpm -F native dev:ios-device ``` Android 真机使用: ```bash pnpm -F native dev:android-device ``` 接入前可以先参考 Expo 的 [Push Notification Services Guide](https://docs.expo.dev/guides/using-push-notifications-services/) 和对应服务商的官方接入文档。 ## 常见问题 确认 `notifications.enabled` 已改为 `true`,然后重新执行 prebuild、重新构建并安装 App。 确认 Apple Developer 中的 App ID 已开启 Push Notifications,并重新下载或生成 Provisioning Profile。 确认以下两份文件来自同一个 Firebase 项目: - 上传到 Expo 的 FCM V1 Service Account JSON - `apps/native/google-services.json` 项目已开启 Enhanced Push Security,但 Access Token 未填写或填写错误。请填写与 Server 中 `EXPO_ACCESS_TOKEN` 相同的值。 # App 管理员与 RBAC (http://page.easystarter.dev/docs/mobile/integrations/rbac) EasyStarter App 与 Web 共享同一套全局 RBAC。服务端在用户记录中保存 `user` 或 `admin` 角色,App 根据会话中的角色显示管理入口。 ## 配置 App 管理功能 ### 开启共享功能开关 修改 `packages/app-config/src/app-config.ts`: ```ts common: { admin: { // 开启付费用户管理 paidUsers: { enabled: true, }, // 开启用户管理和管理操作记录 userManagement: { enabled: true, }, }, auth: { // 其他认证配置…… rbac: { defaultRole: "user", adminRoles: ["admin"], }, }, } ``` `apps/native/configs/app-config.ts` 会读取这些共享值,并暴露 `adminUserManagementEnabled` 和 `adminPaidUsersEnabled`。 请保持 `defaultRole: "user"`,避免所有新账户默认成为管理员。 ### 在 Server 配置初始管理员 本地开发: ```bash title="apps/server/.dev.vars" ADMIN_EMAIL=admin@yourcompany.com ``` 生产环境: ```bash title="apps/server/.env.production" ADMIN_EMAIL=admin@yourcompany.com ``` 推送生产 Secret: ```bash pnpm -F server secrets:bulk:production ``` `ADMIN_EMAIL` 是服务端秘密,不要把它写入 `apps/native/.env*` 或任何 `EXPO_PUBLIC_*` 变量。 它应填写已验证的真实账户邮箱,而不是发件地址或 `supportEmail`。当前只支持一个邮箱。 ### 在 App 中激活管理员 配置 `ADMIN_EMAIL` 后,重新部署 Server 即可。 如果该用户已经在 App 中登录过,让其退出后重新登录。新会话会带上 `admin` 角色,个人中心随后显示管理入口。 ## App 中的管理入口 `apps/native/app/(tabs)/(profile)/index.tsx` 会同时检查登录状态、功能开关和权限: ```tsx const canManageUsers = isAuthenticated && appConfig.adminUserManagementEnabled && hasPermission(user?.role, "user", "list"); const canViewPaidUsers = isAuthenticated && appConfig.adminPaidUsersEnabled && hasPermission(user?.role, "admin", "access"); ``` 当前 App 管理区域包含: - 用户管理 - 付费用户管理 - 积分调整与 Membership 试用赠送 - 管理操作记录 ## 保护 App 管理页面 不要只隐藏个人中心里的菜单。管理路由布局还应检查 `admin:access`: ```tsx if (!isAuthenticated) { return ; } if (!hasPermission(user?.role, "admin", "access")) { return ; } return ; ``` 单个功能的 `_layout.tsx` 可以使用 `AdminFeatureStack` 检查功能开关: ```tsx ``` 这些 App 检查只负责导航与页面显示。所有读取用户、修改角色、封禁、积分和 Membership 操作都必须由服务端 oRPC 再次校验权限。 ## 默认权限 | 权限 | App 中的用途 | | ------------------------ | ------------------- | | `admin:access` | 进入管理区域 | | `user:list` | 查看用户 | | `user:set-role` | 修改用户角色 | | `user:ban` | 封禁和解封用户 | | `credits:adjust` | 调整积分 | | `membership:grant-trial` | 赠送 Membership 试用 | | `operation:list` | 查看管理操作记录 | 普通 `user` 不拥有上述管理权限,`admin` 拥有全部默认管理权限。 ## 撤销管理员 删除或更换 `ADMIN_EMAIL` 不会自动撤销旧管理员。请先通过用户管理把旧管理员改回 `user`,再更换服务端环境变量。 角色改变后,让该用户在 App 中退出并重新登录,以刷新本地会话和管理入口。 # 阿里云 OSS 存储(适合中国大陆业务) (http://page.easystarter.dev/docs/mobile/integrations/storage/aliyun-oss) ## 阿里云 OSS 存储 EasyStarter 内置了阿里云 [对象存储 OSS](https://help.aliyun.com/zh/oss/) 作为存储服务商,可以与默认的 Cloudflare R2 自由切换。服务端通过 OSS REST API V4 签名直接调用,不依赖任何 Node.js SDK,可以在 Cloudflare Workers Runtime 下直接运行。 如果你的服务主要面向中国大陆用户,使用阿里云 OSS 通常能获得更稳定的访问速度和更低的出口流量成本;与上一节的 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 共用同一对 RAM AccessKey,运维上也更简单。 | 项目 | 当前配置 | | --- | --- | | 上传/下载/列举/删除 | OSS REST API V4 + `OSS4-HMAC-SHA256` 签名 | | 服务端 Provider | `apps/server/src/storage/providers/aliyun-oss.ts` | | Provider 注册位置 | `apps/server/src/storage/index.ts` | | 配置开关 | `packages/app-config/src/app-config.ts` 中的 `common.storage.provider` | | 文件访问路径 | `${SERVER_URL}/api/storage/aliyun-oss/`(通过服务端代理,OSS Bucket 无需公开) | EasyStarter 现有的 `avatar` 与 `attachment` 上传类型、文件大小和 MIME 类型限制对所有 Provider 通用,切换到 OSS 后业务代码无需修改。 ## 所需环境变量 ```bash # 与阿里云手机号登录共用同一对 RAM AccessKey ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= # OSS 专属 ALIYUN_OSS_BUCKET= ALIYUN_OSS_REGION= ALIYUN_OSS_ENDPOINT= ``` 变量说明: | 变量 | 含义 | 示例 | | --- | --- | --- | | `ALIBABA_CLOUD_ACCESS_KEY_ID` | RAM 用户 AccessKey ID,服务端调用 OSS 的长期凭证 | `LTAI5tXXXXXXXXXXXXXXXXX` | | `ALIBABA_CLOUD_ACCESS_KEY_SECRET` | RAM 用户 AccessKey Secret,仅创建时显示一次 | `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` | | `ALIYUN_OSS_BUCKET` | OSS Bucket 名称,不含域名 | `your-app-bucket` | | `ALIYUN_OSS_REGION` | Bucket 所在地域 ID,签名时作为 region scope | `cn-hangzhou` | | `ALIYUN_OSS_ENDPOINT` | OSS 访问域名,**不要带 Bucket 前缀和协议头** | `oss-cn-hangzhou.aliyuncs.com` | `ALIYUN_OSS_ENDPOINT` 必须填外网访问域名,例如 `oss-cn-hangzhou.aliyuncs.com`。如果你写成 `your-app-bucket.oss-cn-hangzhou.aliyuncs.com`,Provider 内部会再次拼接一次 Bucket,导致请求路径错误。 如果你已经按照 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 配置过 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,可以直接复用同一对密钥,只需要为同一个 RAM 用户追加 OSS 权限。 ### 开通对象存储 OSS 服务 先确认阿里云账号已经开通对象存储 OSS 服务,并完成实名认证。 产品页面:[阿里云对象存储 OSS](https://www.aliyun.com/product/oss),在页面顶部点击 **立即开通** 即可。 ### 创建 OSS Bucket 官方文档:[创建存储空间](https://help.aliyun.com/zh/oss/user-guide/create-a-bucket-4) 1. 登录 [OSS 控制台](https://oss.console.aliyun.com/) 2. 点击 **Bucket 列表** → **创建 Bucket** 3. 填写 **Bucket 名称**,例如 `your-app-bucket`(全局唯一,3-63 字符,仅小写字母、数字、短横线) 4. 选择 **地域**,例如 `华东1(杭州)`,对应 region ID `cn-hangzhou` 5. **读写权限**保持默认的 **私有**(文件通过服务端代理访问,Bucket 不需要公开) 6. 其它选项按默认即可,点击 **完成创建** 记录以下信息: - Bucket 名称 → `ALIYUN_OSS_BUCKET` - 地域 ID(控制台 Bucket 概览页的 **地域** 字段,例如 `oss-cn-hangzhou` 中的 `cn-hangzhou`)→ `ALIYUN_OSS_REGION` - 外网访问 Endpoint(Bucket 概览页的 **Endpoint(外网访问)** 字段,例如 `oss-cn-hangzhou.aliyuncs.com`)→ `ALIYUN_OSS_ENDPOINT` ### 为 RAM 用户授予 OSS 权限 推荐使用 RAM 用户的 AccessKey,不要直接使用阿里云主账号 AccessKey。如果你已经按照 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 创建过 RAM 用户,可以直接给同一个用户追加授权。 1. 登录 [阿里云 RAM 控制台](https://ram.console.aliyun.com/) 2. 进入 **身份管理** → **用户**,选中目标 RAM 用户 3. 点击 **权限管理** → **新增授权** 4. **资源范围**选择 **账号级别**,在 **权限策略** 中搜索并勾选系统策略 **`AliyunOSSFullAccess`**,然后点击 **确认新增授权** ![在 RAM 新增授权弹窗中勾选 AliyunOSSFullAccess 系统策略](/images/docs/aliyun-oss-ram-policy.png) 这是阿里云官方推荐的做法,挂载系统策略后该 RAM 用户即可读写 OSS。 ### 获取或复用 AccessKey 官方文档:[创建 AccessKey](https://help.aliyun.com/zh/ram/user-guide/create-an-accesskey-pair) 如果还没有 AccessKey: 1. 在 RAM 用户详情页打开 **认证管理** 或 **AccessKey** 标签 2. 点击 **创建 AccessKey**,按提示完成安全校验 3. 创建成功后立即复制保存: - `AccessKey ID` → `ALIBABA_CLOUD_ACCESS_KEY_ID` - `AccessKey Secret` → `ALIBABA_CLOUD_ACCESS_KEY_SECRET` `AccessKey Secret` 只会在创建时显示一次。如果丢失,只能禁用旧密钥并重新创建。 如果你已经为手机号登录配置过同一个 RAM 用户的 AccessKey,直接复用即可,不要再为同一个用户创建多对 AccessKey。 ### 切换 Storage Provider 在 `packages/app-config/src/app-config.ts` 中,把 `common.storage.provider` 从 `"r2"` 改成 `"aliyun-oss"`: ```ts title="packages/app-config/src/app-config.ts" storage: { enabled: true, provider: "aliyun-oss", // 从 "r2" 改成 "aliyun-oss" publicPath: "/api/storage", // ...其余字段保持不变 }, ``` 切换后,所有 `avatar` / `attachment` 上传、下载、列举、删除都会自动走 OSS Provider,业务代码、上传组件、Better Auth 头像逻辑都不需要改。 ### 填入本地与生产环境变量 本地开发写入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` 生产部署写入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` 切换到 OSS 后,`R2_PUBLIC_URL` 不再需要,可以从两个环境文件中删除。`apps/server/wrangler.jsonc` 中的 `r2_buckets` 绑定(`STORAGE`)也不再被读取,可以保留以便随时切回,也可以删除。 ### 推送生产 Secrets 部署到 Cloudflare Workers 前,把生产密钥推送到 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 推送成功后,Worker 运行时通过 `env.ALIBABA_CLOUD_ACCESS_KEY_ID`、`env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`、`env.ALIYUN_OSS_BUCKET`、`env.ALIYUN_OSS_REGION`、`env.ALIYUN_OSS_ENDPOINT` 读取这些变量。更新任意一个值后重新执行这条命令即可生效,不需要因为 Secret 变化重新部署代码。 ### 本地验证文件上传 启动服务端与客户端: ```bash pnpm dev:server pnpm dev:web ``` 登录后在个人资料页上传一张头像。前端会向 `/api/storage/upload` 提交文件,服务端调用 OSS Provider 的 `put` 方法把文件写入 `avatars//...`,并返回如下形式的公共 URL: ``` http://localhost:3001/api/storage/aliyun-oss/avatars//.png ``` 后续读取由服务端的 `/api/storage/aliyun-oss/` 路由代理回 OSS,Bucket 本身保持私有即可。 可以在 OSS 控制台的 **文件管理** 中确认对象已经写入对应前缀;在浏览器中直接打开上面那条公共 URL 也应该能看到图片。 # 存储服务 (http://page.easystarter.dev/docs/mobile/integrations/storage) ## 存储服务 EasyStarter 使用 [Cloudflare R2](https://developers.cloudflare.com/r2/) 作为对象存储服务,用于上传和管理用户文件。目前内置支持以下两种上传类型: | 上传类型 | 说明 | 文件大小限制 | | --- | --- | --- | | `avatar` | 用户头像 | 5 MB | | `attachment` | 附件文件(图片、PDF、文本) | 25 MB | ### 创建 R2 Bucket 官方文档:[R2 Getting started](https://developers.cloudflare.com/r2/get-started/) **方式一:通过 Cloudflare Dashboard** 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. 进入 **R2 Object Storage** 3. 点击 **Create bucket** 4. 输入 bucket 名称,例如 `your-app-bucket` 5. 选择地域(推荐 Automatic) 6. 点击 **Create bucket** **方式二:通过 Wrangler CLI** ```bash pnpm wrangler r2 bucket create your-app-bucket ``` ### 配置 wrangler.jsonc 在 `apps/server/wrangler.jsonc` 的 `r2_buckets` 字段中填入你的 bucket 名称: ```jsonc title="apps/server/wrangler.jsonc" "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-app-bucket" } ], ``` - `binding` 固定为 `STORAGE`,这是 Worker 内部访问 R2 的变量名,不要修改 - `bucket_name` 改为你在 R2 创建的 bucket 名称 ### 开启公开访问并获取 R2_PUBLIC_URL 文件上传后,需要通过公开 URL 访问。推荐使用 R2 的 **Public Access** 功能。 **通过 Cloudflare Dashboard 开启(推荐)** 1. 进入你的 R2 bucket 详情页 2. 点击 **Settings** 标签 3. 在 **Public Access** 区域点击 **Allow Access** 4. 开启后会自动生成一个公开 URL,格式为:`https://pub-xxxxxxxx.r2.dev` 这个 URL 就是 `R2_PUBLIC_URL`。 **自定义域名(可选)** 也可以在 bucket Settings → Custom Domains 绑定自定义域名,例如 `https://cdn.yourdomain.com`,绑定后使用自定义域名作为 `R2_PUBLIC_URL`。 ### 填入 R2_PUBLIC_URL 环境变量 `R2_PUBLIC_URL` 需要填入两个环境变量文件: **本地开发**(`apps/server/.dev.vars`): ```bash title="apps/server/.dev.vars" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` **生产部署**(`apps/server/.env.production`): ```bash title="apps/server/.env.production" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` `.env.production` 用于通过 `pnpm run secrets:bulk:production` 批量推送到 Cloudflare Workers 的 Secrets,不会直接参与构建。 ### 配置存储参数(可选) 存储相关参数在 `packages/app-config/src/app-config.ts` 的 `common.storage` 字段中定义,可按需调整: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "r2", publicPath: "/api/storage", // 文件访问的 API 路径前缀 keyPrefixes: { avatar: "avatars", // 头像文件存储路径前缀 attachment: "attachments", // 附件文件存储路径前缀 }, fallbackPrefix: "files", // 未指定类型时的兜底前缀 allowedTypes: { avatar: ["image/jpeg", "image/png", "image/gif", "image/webp"], attachment: ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"], }, maxFileSizes: { avatar: 5 * 1024 * 1024, // 5 MB attachment: 25 * 1024 * 1024, // 25 MB }, }, ``` ## 扩展其他存储服务 EasyStarter 的存储层基于 `StorageProvider` 接口设计,只需四步即可接入任意存储服务(如 [AWS S3](https://aws.amazon.com/s3/)、[Cloudflare R2](https://developers.cloudflare.com/r2/)、[MinIO](https://min.io/) 等)。 ### 第一步:扩展服务商类型 假设以 S3 为例,在 `packages/app-config/src/types.ts` 中,将新服务商 key 追加到 `SUPPORTED_STORAGE_PROVIDERS`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_STORAGE_PROVIDERS = ["r2", "s3"] as const; ``` ### 第二步:实现 Provider 在 `apps/server/src/storage/providers/` 下新建文件,实现 `StorageProvider` 接口: ```ts title="apps/server/src/storage/providers/s3.ts" import type { StorageProvider } from "../types"; export function createS3StorageProvider({ client, bucket }: { client: S3Client; bucket: string; }): StorageProvider { return { async put(key, data, options) { // 调用 S3 SDK 上传文件 }, async get(key) { // 调用 S3 SDK 下载文件 }, async head(key) { // 调用 S3 SDK 获取元数据 }, async delete(key) { // 调用 S3 SDK 删除文件 }, }; } ``` ### 第三步:注册到存储服务商中 在 `apps/server/src/storage/index.ts` 的 `providers` 对象中注册新 Provider: ```ts title="apps/server/src/storage/index.ts" import { createS3StorageProvider } from "./providers/s3"; const providers: Record = { r2: createR2StorageProvider({ bucket: storage }), s3: createS3StorageProvider({ client: s3Client, bucket: "your-bucket" }), }; ``` ### 第四步:切换配置 在 `packages/app-config/src/app-config.ts` 中将 `storage.provider` 改为新服务商的 key: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "s3", // 切换到新服务商 // ...其余配置保持不变 }, ``` 完成后,所有文件上传、下载、删除操作都会自动通过新 Provider 执行,无需修改业务代码。 # 项目结构 (http://page.easystarter.dev/docs/mobile/project-structure) ## 共享 Monorepo 结构 EasyStarter 是一个由 `Turborepo` 管理的 `pnpm workspace` monorepo。 ```text apps/ web/ Web 客户端 native/ 移动端客户端 server/ Hono API 与 Cloudflare Workers config-ui/ 内部配置工具 packages/ app-config/ 共享业务配置 api-client/ 共享 API 客户端契约 i18n/ 共享多语言资源 shared/ 跨端工具与类型 ``` ## 共享设计原则 - 后端能力统一收敛在 `apps/server` - 跨端配置放在 `packages/*` - Web 和移动端各自维护自己的 UI 与导航 - 共享业务规则不要在两个客户端重复实现 # 完整视频教程 (http://page.easystarter.dev/docs/mobile/video-tutorial) # Skills (http://page.easystarter.dev/docs/web/ai-prompts) 使用方式 [#使用方式] 在 EasyStarter 项目根目录打开你的 AI 编程助手,输入 Skill 名称和你的要求: ```text $easystarter-web-quick-launch 按推荐的最小配置帮我上线 Web 应用。 ``` Web [#web] | 任务 | 命令 | | ------------------ | ----------------------------------------------------------------- | | 快速上线 | `$easystarter-web-quick-launch 按推荐的最小配置帮我上线 Web 应用。` | | 启动本地开发 | `$easystarter-web-dev-start 启动本地 Web 和 Server。` | | 配置 Cloudflare 和 D1 | `$easystarter-web-cloudflare-d1 配置 Cloudflare 和 D1 数据库。` | | 配置邮件 | `$easystarter-web-resend-email 配置 Resend 邮件。` | | 配置认证 | `$easystarter-web-auth 配置 Web 登录和认证。` | | 配置手机号登录 | `$easystarter-web-aliyun-phone-login 配置阿里云手机号登录。` | | 配置存储 | `$easystarter-web-storage 配置 Cloudflare R2 存储。` | | 配置 Stripe | `$easystarter-web-stripe-payments 配置 Stripe 支付。` | | 配置 Creem | `$easystarter-web-creem-payments 配置 Creem 支付。` | | 部署 Server | `$easystarter-web-deploy-server 将 Server 部署到 Cloudflare Workers。` | | 部署 Web | `$easystarter-web-deploy-web 将 Web 部署到 Cloudflare Workers。` | | 修改主题 | `$easystarter-web-theme 修改 Web 主题。` | | 修改落地页 | `$easystarter-web-landing-page 修改落地页。` | | 配置数据分析 | `$easystarter-web-analytics 配置 GA4 和 OpenPanel。` | | 配置积分 | `$easystarter-web-credits 配置 Web 积分系统。` | 功能开发 [#功能开发] | 任务 | 命令 | | --------- | ------------------------------------------- | | 新增 API 路由 | `$easystarter-api-route 为[功能]创建 API 路由。` | | 新增数据表 | `$easystarter-db-schema 为[功能]创建数据库 Schema。` | | 新增 UI 组件 | `$easystarter-component 添加[组件]组件。` | | 新增表单页 | `$easystarter-form-page 为[功能]创建表单页。` | | 新增数据表格 | `$easystarter-data-table 为[资源]创建数据表格。` | | 新增翻译 | `$easystarter-i18n 为[功能]添加翻译。` | # 落地页配置 (http://page.easystarter.dev/docs/web/config/landing-page) ## 落地页配置 落地页由多个独立区块(Block)拼接而成,每个区块对应一个 React 组件。区块列表可自由组合,默认配置定义在代码中,用户也可在应用内实时调整。 --- ## 可用区块 所有区块注册在: ``` apps/web/src/configs/landing-page-component/landing-page-component-registry.tsx ``` | 键名 | 标签 | 类型 | | --- | --- | --- | | `hero-section-23` | Shadcn Hero 23 | hero | | `hero-section-03` | Shadcn Hero 03 | hero | | `tailark-hero` | Tailark Hero | hero | | `features-section-21` | Shadcn Features 21 | features | | `tailark-logo-cloud` | Tailark Logo Cloud | logo-cloud | | `tailark-features` | Tailark Features | features | | `tailark-integrations` | Tailark Integrations | integrations | | `tailark-content` | Tailark Content | content | | `tailark-stats` | Tailark Stats | stats | | `tailark-pricing` | Tailark Pricing | pricing | | `tailark-faqs` | Tailark FAQs | faqs | | `tailark-call-to-action` | Tailark Call To Action | call-to-action | | `tailark-testimonials` | Tailark Testimonials | testimonials | > **注意**:同一 `type` 的区块在落地页中只会显示一个。例如同时选择 `hero-section-23` 和 `hero-section-03`,只有排序靠前的一个会生效。 --- ## 更改默认区块列表 默认落地页区块列表定义在: ```typescript title="apps/web/src/configs/web-config.ts" const defaultLandingPageComponents = [ "hero-section-23", "tailark-logo-cloud", "features-section-21", "tailark-integrations", "tailark-content", "tailark-stats", "tailark-pricing", "tailark-faqs", "tailark-call-to-action", "tailark-testimonials", ] as const satisfies readonly LandingPageComponentKey[]; ``` 直接编辑这个数组,即可更改新用户打开应用时看到的默认落地页结构: - **调整顺序**:将区块键名前后移动,落地页显示顺序随之改变 - **删除区块**:从数组中移除对应键名 - **添加区块**:将注册表中的键名加入数组 --- ## 存储键 用户在应用内自定义落地页后,配置会存入 `localStorage`: | 键 | 内容 | | --- | --- | | `{AppName}-landing-page-components` | 当前区块键名的 JSON 数组 | 若存储的区块列表为空或全部无效,会自动回退到 `defaultLandingPageComponents`。 --- ## 新增自定义区块 ### 创建 React 组件 在 `apps/web/src/components/landing-page/` 下新建组件目录和文件: ``` apps/web/src/components/landing-page/ └── my-section/ └── my-section.tsx ``` ### 注册到区块注册表 在 `landing-page-component-registry.tsx` 中添加新条目: ```typescript title="apps/web/src/configs/landing-page-component/landing-page-component-registry.tsx" import MySection from "@/components/landing-page/my-section/my-section"; export const landingPageComponentMap = { // 现有区块... "my-section": () => , }; ``` ### 添加元数据 在 `landing-page-component-config.ts` 的 `LANDING_PAGE_COMPONENTS` 中补充标签和分组: ```typescript title="apps/web/src/configs/landing-page-component/landing-page-component-config.ts" export const LANDING_PAGE_COMPONENTS = { // 现有条目... "my-section": { label: "My Section", group: "Custom", type: "my-type" }, }; ``` ### 加入默认列表(可选) 如果希望新区块默认显示,将其添加到 `web-config.ts` 的 `defaultLandingPageComponents` 中。 # Web 主题配置 (http://page.easystarter.dev/docs/web/config/theme) ## Web 主题配置 Web 端使用 Base UI 组件,并采用 `apps/web/src/styles/index.css` 中的默认 shadcn/ui 亮色 / 暗色变量。没有自定义主题预设选择器。 ## 亮色 / 暗色模式 用户可以在页头主题开关中选择 **Light**、**Dark** 或 **System**。选择会写入 `localStorage`,并以 `.light` 或 `.dark` class 应用到 ``。 默认颜色定义在 `:root`(亮色)和 `.dark`(暗色)。保留 `@theme inline` 块,以便 `bg-background`、`text-foreground` 等 Tailwind class 继续映射。 ## 修改默认颜色 如果需要换配色,直接改 `apps/web/src/styles/index.css` 里 `:root` 和 `.dark` 的 CSS 变量。不要再加运行时主题预设系统。 # CLI 创建项目 (http://page.easystarter.dev/docs/web/create-project) 购买 EasyStarter 并接受 GitHub collaborator 邀请后,用这条命令创建**你自己的项目**。不用再手动 clone 模板仓库。 模板仓库是私有的。请先接受 collaborator 邀请。如果命令下载模板失败,通常是邀请还没接受。 ### 安装工具 - [`Node.js 22+`](https://nodejs.org/) - [`pnpm 9+`](https://pnpm.io/) - [`git`](https://git-scm.com/) ### 创建项目 在任意空目录执行: ```bash pnpm create easystarter my-app ``` 或: ```bash npx create-easystarter my-app ``` 把 `my-app` 换成你的项目名,必须是小写 kebab-case(例如 `acme-app`,不要写成 `Acme App`)。 这条命令会下载 EasyStarter、按你的名称初始化项目、写入本地环境变量、安装依赖,并可以启动开发服务。 ### 按你的产品选择集成 向导问的是**你的产品**要用哪些能力。现在先选需要的即可,之后还可以在 `packages/app-config` 里改。 | 问题 | 怎么选 | |---|---| | 应用显示名称 | 用户看到的名字,例如 `Acme` | | 登录方式 | 至少选一种:`email-password`、`email-otp`、`github`、`google`、`apple`、`sms` | | Web 支付 | `none`、`stripe`、`creem` 或 `waffo` | | 移动端支付 | `none` 或 `revenuecat` | | 邮件服务 | `cloudflare` 或 `resend` | | 是否启用积分 | 只有要卖积分时才选是 | | 是否安装依赖 | 选是 | | 是否初始化 git | 选是 | 默认是邮箱密码登录、不开支付、邮件用 Cloudflare、不开积分。选完后会迁移本地数据库并启动 Web + Server。 不想逐项回答、直接用默认值: ```bash pnpm create easystarter my-app -y ``` 已经确定技术选型时,也可以直接带参数,例如: ```bash pnpm create easystarter my-app \ --app-name "Acme" \ --auth email-password github \ --payments stripe \ --native-payments revenuecat \ --email resend \ --no-dev ``` `--no-dev` 只创建项目、不启动开发服务。`--auth` 可以传多种登录方式。 ### 打开本地应用 创建完成后(没有传 `--no-dev` 时): | 应用 | 地址 | |---|---| | Web | [http://localhost:3000](http://localhost:3000) | | Server | [http://localhost:3001](http://localhost:3001) | | Extension | [http://localhost:3002](http://localhost:3002) | 如果服务没有自动起来: ```bash cd my-app pnpm dev:web+server ``` 环境变量文件已经生成好了。不要再把 `.example` 文件覆盖上去,否则会冲掉 `BETTER_AUTH_SECRET`。 如果你选了 GitHub、Google、Apple、短信、Resend、Stripe、Creem、Waffo 或 RevenueCat,命令结束时会列出这些功能还要补的密钥。用对应功能前先填上。 ### 连接 Cloudflare `create` 只初始化本地项目,**不会**在 Cloudflare 上创建 D1 / R2,也**不会**部署。 准备绑定你的 Cloudflare 账号时再执行(远程数据库、对象存储、部署都需要这一步): ```bash cd my-app pnpm exec create-easystarter init ``` 或: ```bash npx create-easystarter init ``` 如果还没登录 Cloudflare,会打开浏览器登录,然后创建或复用 `{project}-db` 和 `{project}-bucket`,并把 ID 写回项目。本地开发可以先跑,不必马上执行这一步。 如果要同时做远程 D1 迁移: ```bash pnpm exec create-easystarter init --migrate ``` 远程迁移需要带 D1 编辑权限的 Cloudflare API Token,在 [API Tokens](https://dash.cloudflare.com/profile/api-tokens) 创建。`init` 时可以先跳过 Token,之后把 `CLOUDFLARE_API_TOKEN` 写进环境变量,再执行 `pnpm db:migrate`。 也可以一并写入生产环境地址: ```bash pnpm exec create-easystarter init \ --website-url https://example.com \ --server-url https://api.example.com ``` # Web Data Access (http://page.easystarter.dev/docs/web/database) ## Database The project uses [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) as its database layer. ### Create the D1 database See official docs: [D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) Option 1: Cloudflare Dashboard 1. Sign in to the [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. Go to **Storage & databases → D1 SQL database** 3. Click **Create database** 4. Enter a database name, for example `easysaas-db` 5. Choose a location if needed 6. Click **Create** Once created, copy the `database_id` from the database details page. Option 2: Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` On success, Wrangler outputs a D1 binding snippet that contains the `database_id`. ### Configure the D1 database ID After obtaining your `database_id`, add it to the following two locations. Environment variables: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` For how to obtain `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, see [Cloudflare Integration](/docs/web/integrations/cloudflare). Set `database_id` in: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` and: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler config: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` That means `apps/server/wrangler.jsonc` must use the same `database_id`. ### Run the local database workflow For local database development, use these three commands in this order: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` Generate migration files from `apps/server/src/db/schema`. `pnpm db:migrate:local` Apply the generated migrations to the local D1 database. This command already handles local D1 initialization. `pnpm db:studio:local` Open the local D1 visual UI so you can inspect tables and data. # 部署 Server (http://page.easystarter.dev/docs/web/deploy-server) ## 部署 Server(Cloudflare Workers) EasyStarter 的服务端基于 [Hono](https://hono.dev/),运行在 [Cloudflare Workers](https://workers.cloudflare.com/) 上,通过 D1 作为数据库、R2 作为对象存储。 部署前请确认以下前置工作已完成: - Cloudflare 账号凭据已准备好(参见 [Cloudflare 集成](/docs/web/integrations/cloudflare)) - D1 数据库已创建,已获取 **Database ID**(参见 [数据库](/docs/web/integrations/database)) - R2 存储桶已创建,已获取 **存储桶名称**(参见 [对象存储](/docs/web/integrations/storage)) EasyStarter 支持两种部署方式,按需选择: | 方式 | 适合场景 | | --- | --- | | **方式一:本地 CLI 部署** | 快速上线、一次性部署、完全手动控制 | | **方式二:GitHub 自动部署** | 持续交付、团队协作、推送即部署 | --- ## 方式一:本地 CLI 部署 本地登录 Wrangler 后,手动执行部署命令。 ```bash npx wrangler login ``` ## 环境变量说明 Server 端的变量分为三类,分别放在不同位置: | 类型 | 文件 | 说明 | | --- | --- | --- | | **公开配置** | `apps/server/wrangler.jsonc` → `vars` | 非敏感值,明文写入配置,随代码部署 | | **本地开发** | `apps/server/.dev.vars` | 本地 `wrangler dev` 自动加载,不参与部署 | | **生产 Secrets** | `apps/server/.env.production` | 通过 `wrangler secret bulk` 加密推送到 Workers,不参与构建 | > **不要**将 `.env.production` 提交到 Git。`.dev.vars` 也应加入 `.gitignore`。 ### 更新 `apps/server/wrangler.jsonc` 将你的 Worker 名称、D1 Database ID、R2 存储桶名称和公开变量填入配置: ```jsonc title="apps/server/wrangler.jsonc" { "name": "your-server-worker", // Worker 名称,决定默认访问域名,全局唯一 "main": "src/index.ts", "compatibility_date": "2025-06-15", "compatibility_flags": ["nodejs_compat"], "d1_databases": [ { "binding": "DB", "database_name": "your-db-name", // D1 数据库名称(任意,供你参考) "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // D1 Database ID } ], "vars": { "NODE_ENV": "production", "WEBSITE_URL": "https://your-app.com", // Web 端公开访问地址 "SERVER_URL": "https://your-server.workers.dev", // Server Worker 自身地址 "GITHUB_CLIENT_ID": "your-github-client-id", // 公开值,无需保密 "GOOGLE_CLIENT_ID": "your-google-client-id" // 公开值,无需保密 }, "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-bucket-name" // R2 存储桶名称(任意,供你参考) } ] } ``` | 字段 | 说明 | | --- | --- | | `name` | Worker 名称,部署后默认 URL 为 `https://..workers.dev` | | `database_id` | 创建 D1 数据库时获取的 UUID | | `vars.WEBSITE_URL` | Web 应用地址,用于 Better Auth 的回调 URL、邮件跳转链接等 | | `vars.SERVER_URL` | Server Worker 自身地址,用于 Better Auth 配置和 CORS | | `bucket_name` | 与 Cloudflare 后台创建的 R2 存储桶名称保持一致 | ### 准备生产 Secrets(`.env.production`) 先复制生产环境变量模板(如果文件还没有创建): ```bash cp apps/server/.env.production.example apps/server/.env.production ``` 然后在 `apps/server/.env.production` 中填入所有敏感变量。这个文件不参与构建,只用于下一步的 `wrangler secret bulk` 命令。 ```bash title="apps/server/.env.production" // 环境变量示例:(这一部分请参考 apps/server/.env.production.example 中的内容) BETTER_AUTH_SECRET=your-better-auth-secret GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret R2_PUBLIC_URL=https://your-bucket.your-subdomain.r2.dev REVENUECAT_API_KEY=your-revenuecat-api-key STRIPE_SECRET_KEY=your-stripe-secret-key STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret ``` **注意事项:** - 不使用某项集成(如 RevenueCat)时,对应变量可以留空或删除 ### 部署 Worker ```bash pnpm deploy:server ``` 等价于在 `apps/server` 目录下执行 `wrangler deploy`,将源码编译后发布到 Cloudflare Workers。 首次部署成功后,控制台会输出 Worker 的访问地址: ``` Deployed your-server-worker triggers: https://your-server-worker.your-subdomain.workers.dev ``` 记录这个地址,后续配置 Web 端和更新 `SERVER_URL` 时需要用到。 ### 推送 Secrets 将 `.env.production` 中的所有变量批量加密写入 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 等价于 `wrangler secret bulk .env.production`。推送后,变量以加密形式存储在 Cloudflare 侧,不会出现在部署代码或日志中。 > Secrets 推送和代码部署是独立操作。每次更新敏感变量只需重新推送 Secrets,无需重新部署代码。 ### 运行数据库迁移 将数据库 Schema 应用到 Cloudflare D1。 `pnpm db:migrate` 使用 drizzle-kit 的 D1 HTTP 驱动,需要以下三个变量在 `apps/server/.dev.vars` 中已填写: ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= # Cloudflare 账号 ID CLOUDFLARE_API_TOKEN= # 有 D1 Edit 权限的 API Token CLOUDFLARE_D1_DATABASE_ID= # D1 数据库 UUID ``` 确认后执行: ```bash pnpm db:migrate ``` 迁移成功后,D1 中会创建所有必要的表(用户、会话、订阅、账单等)。 > 每次修改数据库 Schema 后,先执行 `pnpm db:generate` 生成迁移文件,再执行 `pnpm db:migrate` 应用到生产 D1。 ## 验证部署 登录 Cloudflare Dashboard → **Workers & Pages**,选择刚部署的 Worker,在 **Logs** 标签下可以实时查看请求日志,确认服务正常响应。 --- ## 方式二:GitHub 自动部署 将 GitHub 仓库与 Cloudflare 绑定后,每次推送到指定分支都会自动触发构建和部署,无需在本地执行任何命令。 ### 连接 GitHub 仓库 1. 进入 [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. 点击 **Create** → **Workers** → **Connect to Git** 3. 授权 Cloudflare 访问你的 GitHub 账户,选择对应仓库 4. 选择部署分支(通常为 `master`) ### 推送 Secrets 在连接仓库后、首次触发构建前,先通过本地 CLI 将所有 Secrets 推送到 Cloudflare,确保 Worker 启动时所有敏感变量已就绪: ```bash pnpm -F server secrets:bulk:production ``` 等价于 `wrangler secret bulk .env.production`,将 `apps/server/.env.production` 中的所有变量加密写入 Worker Secrets。 > Secrets 推送和代码部署是独立操作。后续只有 Secrets 值变更时才需要重新推送,日常代码更新无需重推。 ### 填写构建配置 在 Cloudflare 的构建设置页面填入以下配置: | 项目 | 值 | | --- | --- | | **根目录** | `/` | | **构建命令** | `pnpm --filter server build` | | **部署命令** | `pnpm --filter server run deploy` | | **版本命令** | `pnpm --filter server run deploy` | > 根目录设置为 `/` 是因为这是 monorepo,pnpm workspace 需要从仓库根目录解析依赖。 ### 禁止非生产分支构建 保存构建配置后,**取消勾选非生产分支构建**。 此步骤不可跳过。Server Worker 使用固定名称——如果 Cloudflare 对非生产分支(如 `feature/x`)触发构建并部署,会直接覆盖同一个 Worker,导致生产流量指向未完成的代码,并可能对线上 D1 数据库执行未经验证的迁移操作。 ### 运行数据库迁移 自动部署**不会**自动执行数据库迁移。首次部署完成后,仍需在本地手动运行: ```bash pnpm db:migrate ``` 确保 `apps/server/.dev.vars` 中已填写: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` > 后续每次修改 Schema 时,同样需要在本地执行 `pnpm db:generate` + `pnpm db:migrate`。 配置完成后,每次向目标分支推送代码,Cloudflare 都会自动触发构建并部署最新版本。可在 **Workers & Pages → 你的 Worker → Deployments** 中查看每次部署的状态和日志。 --- ## 自定义域名(推荐) 1. 进入 **Workers & Pages** → 选择你的 Server Worker → **Settings → Domains & Routes** 2. 点击 **Add Custom Domain**,输入已托管在 Cloudflare 的域名,例如 `api.yourdomain.com` 3. 绑定成功后,按以下说明更新相关配置 ### 为什么必须更新这两个字段 `SERVER_URL` 和 `WEBSITE_URL` 不是普通环境变量——它们写在 `wrangler.jsonc` 的 `vars` 块中,会在 `wrangler deploy` 时**编译进 Worker Bundle**,运行时直接读取。改了值之后必须重新部署才能生效。 这两个字段在认证系统中扮演关键角色: | 字段 | 用途 | | --- | --- | | `SERVER_URL` | Better Auth 的 `baseURL`;OAuth 回调地址(`/api/auth/callback/github` 等);Cookie 的 `domain` 和 `secure` 策略 | | `WEBSITE_URL` | Better Auth 的 `trustedOrigins`(CORS 白名单);邮件中的跳转链接 | 如果这两个值与实际域名不匹配,OAuth 登录回调会 404,跨域请求会被 CORS 拦截,会话 Cookie 无法写入。 ### 需要修改的文件 ```jsonc title="apps/server/wrangler.jsonc" "vars": { "WEBSITE_URL": "https://your-app.com", // Web 端正式域名 "SERVER_URL": "https://api.yourdomain.com" // Server 自定义域名(刚绑定的) } ``` Web 端也持有 Server 的地址,用于前端直接调用 API: ```jsonc title="apps/web/wrangler.jsonc" "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com" // 与 SERVER_URL 保持一致 } ``` **OAuth 应用后台** 如果你绑定了新的 `SERVER_URL`,需要同步更新 OAuth 应用(GitHub / Google)的回调地址: - GitHub:**Settings → Developer settings → OAuth Apps** → 更新 **Authorization callback URL** 为 `https://api.yourdomain.com/api/auth/callback/github` - Google:**Google Cloud Console → 凭据 → OAuth 2.0 客户端** → 更新**已授权的重定向 URI** ### 重新部署使配置生效 同时部署 Server 和 Web(因为两边都有改动): ```bash pnpm deploy ``` > `vars` 是随代码打包的静态配置,不是 Secret。每次修改 `wrangler.jsonc` 的 `vars` 都必须重新执行部署,仅推送 Secrets 不会更新这些值。 # 部署 Web (http://page.easystarter.dev/docs/web/deploy-web) ## 部署 Web(Cloudflare Workers) EasyStarter 的 Web 应用基于 [TanStack Start](https://tanstack.com/start/latest),以 SSR 模式运行在 Cloudflare Workers 上。Web Worker 通过 [Service Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) 与 Server Worker 直连,无需经过公网。 **部署 Web 前,必须先完成 [Server 部署](/docs/web/deploy-server)。** Service Binding 依赖 Server Worker 已存在。 EasyStarter 支持两种部署方式,按需选择: | 方式 | 适合场景 | | --- | --- | | **方式一:本地 CLI 部署** | 快速上线、一次性部署、完全手动控制 | | **方式二:GitHub 自动部署** | 持续交付、团队协作、推送即部署 | --- ## 方式一:本地 CLI 部署 本地登录 Wrangler 后,手动执行部署命令。 ```bash npx wrangler login ``` ## 环境变量说明 Web 端的所有变量都是 `VITE_` 前缀的**公开配置**,Vite 在构建时会将其注入到前端代码中。本地 CLI 部署时放在 `wrangler.jsonc` 的 `vars` 中;CI / 远程部署时还需要配置到平台的构建环境变量中。 | 类型 | 文件 | 说明 | | --- | --- | --- | | **本地开发** | `apps/web/.env.development` | 本地 `wrangler dev` 自动加载,指向 localhost | | **本地生产部署** | `apps/web/wrangler.jsonc` → `vars` | 随代码构建打包,部署到 Cloudflare | | **CI / 远程部署** | CI 构建环境变量 / 机密 | 填入 `apps/web/.env.production` 中的所有变量 | > Web 端不使用 `wrangler secret bulk`。`apps/web/.env.production` 可作为生产构建变量清单;CI / 远程部署时,需要把其中的变量配置到平台的构建环境变量或机密中。 如果还没有创建生产变量清单,先复制模板: ```bash cp apps/web/.env.production.example apps/web/.env.production ``` 然后在 `apps/web/.env.production` 中填入生产环境的 `VITE_` 变量。这个文件不参与 `wrangler secret bulk`,主要用于同步到 CI / 远程部署平台的构建环境变量。 ### 更新 `apps/web/wrangler.jsonc` 填入 Worker 名称、Server Service Binding 和公开变量: ```jsonc title="apps/web/wrangler.jsonc" { "name": "your-web-worker", // Web Worker 名称,全局唯一 "compatibility_date": "2025-09-02", "compatibility_flags": ["nodejs_compat"], "main": "@tanstack/react-start/server-entry", "assets": { "not_found_handling": "single-page-application" }, "services": [ { "binding": "API_SERVICE", "service": "your-server-worker" // 必须与 Server wrangler.jsonc 的 name 完全一致 } ], "vars": { "VITE_SERVER_URL": "https://your-server-worker.your-subdomain.workers.dev", "VITE_APP_URL": "https://your-web-worker.your-subdomain.workers.dev" } } ``` | 字段 | 说明 | | --- | --- | | `name` | Web Worker 名称,决定默认访问 URL | | `services[0].service` | **必须与 Server 的 `name` 完全一致**,否则 Service Binding 无法建立 | | `vars.VITE_SERVER_URL` | Server 地址,用于 oRPC 客户端和 Better Auth 客户端 | | `vars.VITE_APP_URL` | Web 应用自身地址,用于 SEO 规范链接、OAuth 回调等 | ### 部署 Web Worker ```bash pnpm deploy:web ``` Wrangler 会先执行 `vite build` 生成 SSR 产物,再将产物发布到 Cloudflare Workers。 部署成功后,控制台会输出访问地址: ``` Deployed your-web-worker triggers: https://your-web-worker.your-subdomain.workers.dev ``` ## 验证部署 访问 Web Worker 地址,逐项确认: - [ ] 首页正常加载 - [ ] 用户注册 / 登录(邮箱 + OAuth) - [ ] 支付结账流程(需 Stripe 配置完成) - [ ] 文件上传(需 R2 配置完成) --- ## 方式二:GitHub 自动部署 将 GitHub 仓库与 Cloudflare 绑定后,每次推送到指定分支都会自动触发构建和部署。 > Web 端不使用 Workers Secrets,但 GitHub 自动部署仍需要填写构建配置和构建环境变量。 ### 连接 GitHub 仓库 1. 进入 [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** 2. 点击 **Create** → **Workers** → **Connect to Git** 3. 授权 Cloudflare 访问你的 GitHub 账户,选择对应仓库 4. 选择部署分支(通常为 `master`) ### 填写构建配置 在构建设置页面填入以下配置: | 项目 | 值 | | --- | --- | | **根目录** | `/` | | **构建命令** | `pnpm --filter web build` | | **部署命令** | `pnpm --filter web run deploy` | | **版本命令** | `pnpm --filter web run deploy` | > 根目录设置为 `/` 是因为这是 monorepo,pnpm workspace 需要从仓库根目录解析依赖。 如果使用 CI / 远程部署,不要依赖本地的 `apps/web/.env.production` 文件。需要在 CI 的构建环境变量中,把 `apps/web/.env.production` 里的所有变量都填进去;区分变量和机密。 ![Cloudflare 构建环境变量配置入口](/images/cloudflare-build-variables.png) ### 禁止非生产分支构建 保存构建配置后,**取消勾选非生产分支构建**。 此步骤不可跳过。Web Worker 使用固定名称——非生产分支构建会直接覆盖同一个 Worker,将线上 Web 应用替换为未完成的代码。由于 Web Worker 通过 Service Bindings 绑定 Server Worker,版本不匹配还可能导致 API 连接异常。 配置完成后,每次向目标分支推送代码,Cloudflare 都会自动触发构建并部署最新版本。可在 **Workers & Pages → 你的 Worker → Deployments** 中查看每次部署的状态和日志。 --- ## 自定义域名(推荐) 1. 进入 Cloudflare Dashboard → **Workers & Pages** → 选择你的 Web Worker 2. 进入 **Settings → Domains & Routes** 3. 点击 **Add Custom Domain**,输入域名(需托管在 Cloudflare),例如 `app.yourdomain.com` 4. 绑定成功后,按以下说明更新相关配置 ### 为什么必须同步更新多处配置 `VITE_APP_URL` 和 `VITE_SERVER_URL` 写在 `wrangler.jsonc` 的 `vars` 中,随构建打包进 Worker Bundle。改了值必须重新部署才能生效。 同时,Server 端也引用了 `WEBSITE_URL`(Better Auth trusted origins),如果 Web 的域名变了,Server 侧也需要同步,否则认证请求会被 CORS 拦截。 ### 需要修改的文件 ```jsonc title="apps/web/wrangler.jsonc" "vars": { "VITE_SERVER_URL": "https://api.yourdomain.com", // 与 Server 自定义域名一致 "VITE_APP_URL": "https://app.yourdomain.com" // Web 新绑定的自定义域名 } ``` ```jsonc title="apps/server/wrangler.jsonc" "vars": { "WEBSITE_URL": "https://app.yourdomain.com", // 同步更新为 Web 新域名 "SERVER_URL": "https://api.yourdomain.com" } ``` ### 重新部署使配置生效 两边都有改动,同时部署: ```bash pnpm deploy ``` 等价于先执行 `pnpm deploy:server`,再执行 `pnpm deploy:web`。 > `vars` 是静态配置,随代码打包。每次修改 `wrangler.jsonc` 的 `vars`,都必须重新部署才能生效。 # Web 快速开始 (http://page.easystarter.dev/docs/web/getting-started) ## 准备 无论是 Web 端还是移动端,先完成工作区级别的初始化: ### 安装必要工具 确保你的开发环境中已安装以下工具: - 安装 [`Node.js 20+`](https://nodejs.org/) - 安装 [`pnpm 9+`](https://pnpm.io/) - 安装 [`git`](https://git-scm.com/) ### 克隆仓库 克隆仓库并进入项目根目录,以便开始开发: ```bash # clone 仓库 git clone https://github.com/sunshineLixun/easystarter.git your-project-name # 进入项目根目录 cd your-project-name # 移除默认的 origin git remote remove origin # 添加你自己的 origin git remote add origin https://github.com/your-username/your-project-name.git # 推送到 origin git push -u origin main ``` ### 安装依赖 执行以下命令安装项目所需的所有依赖包: ```bash pnpm install ``` {props.children} # 简介 (http://page.easystarter.dev/docs/web) import { File, Files, Folder } from "fumadocs-ui/components/files"; ## 欢迎使用 EasyStarter EasyStarter 是一个面向 SaaS 场景的现代全栈模板,也是一个标准的 SaaS monorepo。它把 Web 应用、移动端、服务端 API、数据库、认证、支付、邮件、存储和国际化等常见基础设施提前组织在同一个工程里,让你可以把主要精力放在业务本身,而不是反复搭脚手架。 这种 monorepo 结构让 EasyStarter 可以把前端、后端、移动端和共享能力放在同一个工程中统一管理。它的优势是共享代码更直接、跨端协作更顺畅、功能迭代一致性更高,尤其适合 SaaS 场景下常见的认证、支付、订阅、权限和多语言能力复用。 ## 现代化 AI SaaS 模板 在 AI 辅助开发越来越普遍的情况下,monorepo 的价值会更明显。因为 AI 更擅长在一个完整、连续的上下文里理解系统结构,而 monorepo 正好把前端、后端、移动端、共享包和配置集中在同一个代码仓库中。这意味着 AI 更容易看清模块之间的依赖关系,理解一项功能会影响哪些应用和共享逻辑,从而减少只改一端、漏改另一端的问题。对于 SaaS 项目来说,这种优势尤其明显。像认证、支付、订阅、权限、多语言、存储这类能力,通常会同时涉及 Web、Server、Native 和共享配置。放在 monorepo 里,AI 可以更高效地做跨模块检索、重构、补全、批量修改和一致性检查,也更容易保持架构统一和代码风格一致。 如果你希望快速启动一个具备真实商业能力的产品,而不是从零拼接一套技术栈,EasyStarter 的目标就是给你一个足够清晰、足够完整、也足够容易继续演进的基础工程。 默认围绕 SaaS 产品最常见的几层能力展开: - **Web 应用**:基于 [React 19](https://react.dev/)、[TanStack Start](https://tanstack.com/start/latest) 和 [shadcn/ui](https://ui.shadcn.com/),负责公开页面、认证流程和后台界面 - **Server API**:基于 [Hono](https://hono.dev/) 运行在 [Cloudflare Workers](https://workers.cloudflare.com/) 上,处理认证、支付、存储和业务接口 - **移动端应用**:基于 [React Native](https://reactnative.dev/) 和 [Expo](https://expo.dev/),复用业务能力并承接移动端场景 - **共享包**:把配置、类型、国际化和通用能力沉淀到 `packages/*`,减少跨端重复实现 这不是一个只包含 UI 的模板,也不是一个只适合演示的 boilerplate。它更接近一个可以继续扩展的 SaaS 基础工程。 ## 项目结构 ## 选择客户端文档 - [Web 文档](/docs/web):浏览器页面、Dashboard UI、文档站与 Web 端结账 - [移动端文档](/docs/mobile):Expo App、深链、移动端认证与应用商店发布 ## 热门指南 - [Web 快速开始](/docs/web/getting-started) - [Web 项目结构](/docs/web/project-structure) - [Web 认证系统](/docs/web/integrations/authentication) - [移动端快速开始](/docs/mobile/getting-started) - [移动端认证系统](/docs/mobile/integrations/authentication) - [部署指南](/docs/web/deploy-web) ## 核心能力 ### 营销网站 - 基于 [shadcn/ui](https://ui.shadcn.com/) 和 [Tailwind CSS](https://tailwindcss.com/) 的响应式页面 - 价格和订阅页面预览 - 基于 [Fumadocs](https://fumadocs.vercel.app/) 的 MDX 文档站与多语言支持 - 深色和浅色主题切换 ### 认证 - 基于 [Better Auth](https://better-auth.com/) 的跨端身份系统 - 默认支持邮箱密码和 OAuth 登录 - 移动端内置 Apple 原生登录支持 ### 支付与账单 - **Web**:基于 [Stripe](https://stripe.com/) 的结账和 Webhook 流程 - **移动端**:基于 [RevenueCat](https://www.revenuecat.com/) 的内购和权益流转 - 通过 `app-config` 共享价格目录 ### 数据库与 ORM - 基于 [Drizzle ORM](https://orm.drizzle.team/) 的强类型数据层 - 服务端数据库基于 [Cloudflare D1](https://developers.cloudflare.com/d1/) ### API 层 - 基于 [Hono](https://hono.dev/) 的服务端接口层 - 基于 [oRPC](https://orpc.dev/) 和 [Zod](https://zod.dev/) 的端到端类型契约 - Web 和移动端共享同一套 API 契约 ### 国际化 - Web、Server、移动端共享的 i18n 架构 - 通过 `@repo/i18n` 统一管理多语言资源 ## 适合什么项目? EasyStarter 适合这些场景: - 你想尽快启动一个 SaaS MVP,但不想从认证、支付、邮件这些基础设施重搭 - 你希望 Web、Server、移动端共享同一套核心业务配置和类型 - 你需要一个可以持续扩展的工程基础 ## 常见问题 ### EasyStarter 是纯前端模板吗? 不是。EasyStarter 同时包含 Web 前端、Server API、移动端、数据库接入、认证、支付和邮件能力。 ### EasyStarter 适合直接上线吗? 它的定位是生产可用的基础工程。基础设施已经具备,但你仍然需要补齐业务相关的模型、权限、页面和流程。 ## 下一步 如果你第一次接触这个项目,建议按这个顺序阅读: 1. [Web 快速开始](/docs/web/getting-started) 或 [移动端快速开始](/docs/mobile/getting-started) 2. [项目结构](/docs/web/project-structure) 3. [Cloudflare 集成](/docs/web/integrations/cloudflare) 和 [数据库](/docs/web/integrations/database) # 数据分析 (http://page.easystarter.dev/docs/web/integrations/analytics) ## 数据分析 EasyStarter Web 端已内置两套数据分析方案,均为可选,未配置环境变量时自动跳过加载: | 方案 | 用途 | | --- | --- | | [Google Analytics 4](https://analytics.google.com/) | 通用流量分析、转化漏斗、受众报告 | | [OpenPanel](https://openpanel.dev/) | 开源、隐私友好的产品分析(事件、留存、漏斗) | 两者同时启用、互不干扰;只用其中之一也可以,把另一个环境变量留空即可。 建议只埋关键漏斗事件,例如注册完成、登录成功、订阅支付、核心生成任务完成、作品提交等。模板默认保留 GA4 的页面浏览统计,OpenPanel 不自动监听每次路由变化,避免免费额度被普通页面切换快速消耗。 ## Google Analytics 4 ### 创建 GA4 媒体资源并获取 Measurement ID 官方文档:[查找 Google 跟踪 ID / Measurement ID](https://support.google.com/analytics/answer/12270356) 1. 前往 [analytics.google.com](https://analytics.google.com/) 登录账号 2. 进入 **管理(Admin) → 创建 → 媒体资源(Property)**,填入产品名、时区、币种 3. 在新建的媒体资源下选择 **数据流(Data Streams) → 添加流 → 网站** 4. 输入网站 URL(例如 `https://yourdomain.com`),完成创建 5. 在数据流详情页复制 **测量 ID(Measurement ID)**,格式为 `G-XXXXXXXXXX` ### 填入环境变量 **本地开发**(`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX ``` **本地生产部署**(`apps/web/wrangler.jsonc` 的 `vars` 字段,明文,无需 Secret): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_GA_MEASUREMENT_ID": "G-XXXXXXXXXX", // ... } } ``` ```bash title="apps/web/.env.production" VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX ``` > `VITE_` 前缀的变量会被打包进客户端 bundle,不属于敏感凭据,直接写在 `wrangler.jsonc` / CI 构建变量中即可,不需要 `pnpm run secrets:bulk:production`。`.env.production` 已加入 `.gitignore`,不会被提交。 ### 按需上报关键事件 Web 端提供显式调用的统计 helper。业务代码只需要在真正有分析价值的转化节点调用: ```ts import { trackGoogleEvent } from "@/lib/analytics/google-analytics"; import { trackOpenPanelEvent } from "@/lib/analytics/openpanel"; trackGoogleEvent("sign_up", { method: "google", }); trackOpenPanelEvent("subscription_started", { plan: "pro", }); ``` 如果某个页面确实需要作为产品漏斗的一步,可以手动记录 OpenPanel screen view: ```ts import { trackOpenPanelScreenView } from "@/lib/analytics/openpanel"; trackOpenPanelScreenView("/pricing"); ``` 不要把所有路由切换都当成产品事件。先定义 3-5 个关键漏斗节点,再逐步补充事件,比一开始全量上报更容易控制额度,也更容易读懂数据。 ## OpenPanel [OpenPanel](https://openpanel.dev/) 是开源的产品分析平台,可自托管也可使用 Cloud 版(免费额度对个人项目足够)。EasyStarter 默认接入 Cloud 版。 ### 注册 OpenPanel 并创建项目 官方文档:[Web SDK](https://openpanel.dev/docs/sdks/web) 1. 前往 [openpanel.dev](https://openpanel.dev/) 注册账号 2. 在 Dashboard 中点击 **Create Project** 3. 填写 **Project name** 4. 保持 **Website** 开启,关闭暂时不需要的 **App**、**Backend / API** 5. 在 **Domain** 填写生产网站域名,例如 `https://yourdomain.com` 6. 在 **Allowed domains** 填写允许写入事件的域名,例如 `https://yourdomain.com` 7. 点击 **Create project** 8. 创建完成后,在项目的客户端信息中复制 Website 对应的 **Client ID**(UUID 格式) ### 填入环境变量 **本地开发**(`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` **本地生产部署**(`apps/web/wrangler.jsonc`): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_OPENPANEL_CLIENT_ID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // ... } } ``` ```bash title="apps/web/.env.production" VITE_OPENPANEL_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` > 同样属于公开变量。CI 部署时把它配置到平台的**构建环境变量(Build variables)**中即可,不必使用 Secrets。 # 阿里云手机号登录(适合中国大陆业务) (http://page.easystarter.dev/docs/web/integrations/authentication/aliyun-phone-auth) ## 阿里云手机号登录 EasyStarter 已经内置基于 [Better Auth phone-number plugin](https://www.better-auth.com/docs/plugins/phone-number) 的手机号登录。服务端通过阿里云号码认证服务 Dypnsapi 发送和校验短信验证码,客户端继续使用 Better Auth 的 `phoneNumber.sendOtp` 与 `phoneNumber.verify`,不需要额外新增自定义认证接口。 如果你的产品主要在国内部署,建议把手机号登录作为首选甚至唯一登录方式。国内用户对手机号验证码登录的接受度最高,GitHub / Google / Apple 这类 OAuth 登录在国内访问稳定性、账号覆盖率和合规配置上都更麻烦;邮箱密码登录也可以保留,但不是必须。也就是说,面向国内场景时,只配置阿里云手机号登录即可,其它登录方式都可以不启用、不申请、不写环境变量。 当前内置流程只支持中国大陆手机号: | 项目 | 当前配置 | | --- | --- | | 手机号格式 | `+86` E.164 格式,例如 `+8613800138000` | | 发送接口 | `SendSmsVerifyCode` | | 校验接口 | `CheckSmsVerifyCode` | | 服务端 Provider | `apps/server/src/sms/providers/aliyun.ts` | | Better Auth 配置 | `apps/server/src/lib/auth.ts` | ## 所需环境变量 ```bash ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= ``` 这两个值是服务端调用阿里云 OpenAPI 的长期访问凭证。不要提交到 Git,也不要放到前端环境变量中。 如果你只保留手机号登录,`GITHUB_CLIENT_ID`、`GITHUB_CLIENT_SECRET`、`GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET` 等 OAuth 变量可以不配置。生产环境只需要保留 Better Auth 会话所需的基础变量和这里的阿里云 AccessKey。 ### 开通号码认证服务 先确认阿里云账号已经开通号码认证服务,并且账号可调用 Dypnsapi 的短信认证接口。 官方接口文档:[SendSmsVerifyCode](https://api.aliyun.com/document/Dypnsapi/2017-05-25/SendSmsVerifyCode) 阿里云文档中说明,`SendSmsVerifyCode` 是号码认证服务的短信验证码发送接口。它使用 Dypnsapi 产品下的 `2017-05-25` API 版本,并且授权 Action 为 `dypns:SendSmsVerifyCode`。 ### 创建 RAM 用户并授权 推荐使用 RAM 用户的 AccessKey,不要直接使用阿里云主账号 AccessKey。 1. 登录 [阿里云 RAM 控制台](https://ram.console.aliyun.com/) 2. 进入 **身份管理** → **用户** 3. 点击 **创建用户** 4. 填写必要的信息 5. 在访问配置中选择 **使用永久 AccessKey 访问** 6. 创建完成后,页面会自动回到用户列表页面 ### 获取 AccessKey ID 和 AccessKey Secret 1. 在用户列表找到刚创建的 RAM 用户,AccessKey 这一列会显示AccessKey ID、AccessKey Secret,点击复制 `AccessKey ID`、`AccessKey Secret` 只会在创建时显示一次,后续无法再次查看。如果丢失,只能禁用旧密钥并重新创建新的 AccessKey。 ### 为 RAM 用户授权调用 Dypnsapi 的权限 1. 在用户列表找到刚创建的 RAM 用户,点击`登录名称 / 显示名称`进入用户详情页 2. 点击 **权限管理** → **新增授权** 3. 在 **权限策略** 步骤,搜索框中搜索 `dypns`,找到 **AliyunDypnsReadOnlyAccess**、 **AliyunDypnsFullAccess** 权限,点击选择 你也可以选择 **PowerUserAccess** 权限,这种权限 提供对阿里云服务和资源的完全访问权限,包含了短信、OSS等等服务的全部权限。为了最小权限原则,建议只授权 **AliyunDypnsReadOnlyAccess**、 **AliyunDypnsFullAccess**,它包含了号码认证服务的全部权限,但不涉及其它服务。 4. 确认授权 ### 填入本地与生产环境变量 本地开发写入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` 生产部署写入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ``` `.env.production` 只用于通过 Wrangler 批量推送到 Cloudflare Workers Secrets,不参与前端构建,也不应该提交到仓库。 ### 推送生产 Secrets 部署到 Cloudflare Workers 前,把生产密钥推送到 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 推送成功后,Worker 运行时可以通过 `env.ALIBABA_CLOUD_ACCESS_KEY_ID` 和 `env.ALIBABA_CLOUD_ACCESS_KEY_SECRET` 读取密钥。更新密钥时重新执行这条命令即可,不需要因为 Secret 变化而重新部署代码。 ### 保持阿里云验证码参数不变 EasyStarter 内置 Provider 已经按阿里云 `SendSmsVerifyCode` 文档锁定以下参数: ```ts title="apps/server/src/sms/providers/aliyun.ts" const ALIYUN_SMS_VERSION = "2017-05-25"; const ALIYUN_SMS_SIGN_NAME = "速通互联验证码"; const ALIYUN_SMS_TEMPLATE_CODE = "100001"; ``` 这三个值不要修改: | 常量 | 对应阿里云参数 | 为什么不能改 | | --- | --- | --- | | `ALIYUN_SMS_VERSION` | OpenAPI 版本 | Dypnsapi 的 `SendSmsVerifyCode` 接口版本就是 `2017-05-25` | | `ALIYUN_SMS_SIGN_NAME` | `SignName` | 文档示例使用号码认证服务赠送签名 `速通互联验证码`,该接口暂不支持普通自定义签名 | | `ALIYUN_SMS_TEMPLATE_CODE` | `TemplateCode` | 赠送签名必须搭配赠送模板,当前模板 Code 为 `100001` | 这里使用的是号码认证服务 Dypnsapi 的短信认证接口,不是普通短信服务 Dysmsapi 的 `SendSms`。不要把普通短信服务里申请的 `SMS_...` 模板码替换到这里。 ### 本地验证手机号登录 启动服务端与客户端后,在登录页选择手机号登录: ```bash pnpm dev:server pnpm dev:web ``` 输入中国大陆手机号后,前端会调用: ```bash POST /api/auth/phone-number/send-otp ``` 提交验证码时会调用: ```bash POST /api/auth/phone-number/verify ``` 服务端会把 `+86` 号码拆成阿里云需要的 `CountryCode=86` 和本地手机号,然后由阿里云生成、发送并校验验证码。 ## 常见问题 ### 为什么不自己生成验证码? 当前实现使用 `TemplateParam={"code":"##code##","min":"5"}`,让阿里云生成验证码。这样后续校验可以继续调用 `CheckSmsVerifyCode`,服务端不需要自己保存验证码。 ### 为什么不能换成自己的短信签名? `SendSmsVerifyCode` 属于号码认证服务。阿里云文档说明,赠送签名必须搭配赠送模板使用,并且暂不支持使用自定义签名。当前内置值与官方文档示例保持一致。 ### AccessKey 泄露怎么办? 立即在 RAM 控制台禁用或删除泄露的 AccessKey,重新创建新的 AccessKey,并重新推送 `apps/server/.env.production` 到 Workers Secrets。 # 邮箱 OTP 登录 (http://page.easystarter.dev/docs/web/integrations/authentication/email-otp) ## 邮箱 OTP 登录 EasyStarter 内置了基于 [Better Auth Email OTP 插件](https://www.better-auth.com/docs/plugins/email-otp) 的邮箱验证码登录功能。用户只需输入邮箱,即可收到一次性验证码完成登录,无需设置密码。 ### 工作流程 1. 用户在登录页输入邮箱地址 2. 服务端通过 [邮件服务](/docs/web/integrations/email) 发送一次性验证码到该邮箱 3. 用户输入收到的验证码 4. 服务端验证通过后完成登录(如用户不存在则自动注册) ### 启用邮箱 OTP 登录 邮箱 OTP 登录通过 `packages/app-config/src/app-config.ts` 中的配置开关控制: ```ts title="packages/app-config/src/app-config.ts" auth: { methods: { emailOtpEnabled: true, }, } ``` ### 前置条件 邮箱 OTP 登录依赖邮件发送能力,请确保已完成 [邮件服务](/docs/web/integrations/email) 配置。 ### OTP 参数配置 验证码的行为参数在 `packages/app-config/src/app-config.ts` 的 `auth.otp.email` 中统一配置: ```ts title="packages/app-config/src/app-config.ts" auth: { otp: { email: { // 验证码位数 otpLength: 6, // 验证码有效期(秒) expiresInSeconds: 300, // 单个验证码最大尝试次数 allowedAttempts: 3, // 客户端重发冷却时间(秒) resendCooldownSeconds: 60, }, }, } ``` ### 速率限制 服务端对邮箱 OTP 相关接口配置了独立的速率限制,防止滥用: ```ts title="apps/server/src/lib/auth.ts" rateLimit: { customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 }, "/sign-in/email-otp": { window: 60, max: 10 }, }, } ``` - 发送验证码:每 60 秒最多 3 次 - 验证登录:每 60 秒最多 10 次 # 邮箱密码登录 (http://page.easystarter.dev/docs/web/integrations/authentication) ## 邮箱密码登录 EasyStarter 使用 [Better Auth](https://www.better-auth.com/) 作为认证方案,内置了邮箱 + 密码登录。 服务端配置位于 `apps/server/src/lib/auth.ts`。 如果你启用了邮箱注册验证或忘记密码,还需要先完成 [邮件服务](/docs/web/integrations/email) 配置。 ## 所需环境变量 ```bash BETTER_AUTH_SECRET= ``` ### 获取 `BETTER_AUTH_SECRET` `BETTER_AUTH_SECRET` 用于 Better Auth 签名和加密会话数据,必须是一个足够长的随机字符串。 你可以直接自己生成一个,例如: ```bash openssl rand -base64 32 ``` 复制生成结果,填到: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-random-secret ``` ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-random-secret ``` ### 填入环境变量 本地开发建议统一放到 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" BETTER_AUTH_SECRET=your-long-random-secret ``` 生产部署时,敏感值放到 `apps/server/.env.production`: ```bash title="apps/server/.env.production" BETTER_AUTH_SECRET=your-long-random-secret ``` ## 邮箱密码认证功能 EasyStarter 当前的邮箱密码认证配置负责: - 邮箱密码注册和登录 - 邮箱验证 - 忘记密码 - 基于 Cookie 的会话管理 核心配置文件: ```bash apps/server/src/lib/auth.ts ``` # 社媒登录 (http://page.easystarter.dev/docs/web/integrations/authentication/social-login) ## 社媒登录 EasyStarter 内置了以下社媒登录方式: - GitHub OAuth 登录 - Google OAuth 登录 服务端配置位于 `apps/server/src/lib/auth.ts`。其中: - GitHub 回调地址:`{SERVER_URL}/api/auth/callback/github` - Google 回调地址:`{SERVER_URL}/api/auth/callback/google` ## 所需环境变量 ```bash GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= ``` ### 创建 GitHub OAuth App GitHub OAuth 用于网页端和原生端的 GitHub 登录。 GitHub 开发者后台: [GitHub Developer Settings](https://github.com/settings/developers) 1. 登录 GitHub,进入 `Settings` 2. 打开 `Developer settings` 3. 进入 `OAuth Apps` 4. 点击 `New OAuth App` 5. 填写应用信息 关键字段建议这样填: - `Application name`:你的产品名 - `Homepage URL`:你的前端地址,例如 `https://yourdomain.com` - `Authorization callback URL`:`https://yourdomain.com/api/auth/callback/github` 例如你的服务端地址是: ```bash SERVER_URL=https://server.yourdomain.com ``` 那么这里就应该填写: ```bash https://server.yourdomain.com/api/auth/callback/github ``` 开发环境下,`easystarter` 默认服务端地址是 `http://localhost:3001`,所以这里通常填写: ```bash http://localhost:3001/api/auth/callback/github ``` 创建完成后你会拿到: - `Client ID` -> 对应 `GITHUB_CLIENT_ID` - `Client Secret` -> 对应 `GITHUB_CLIENT_SECRET` ### 创建 Google OAuth Client Google OAuth 用于网页端和原生端的 Google 登录。 Google Cloud 控制台: [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 1. 登录 Google Cloud Console 2. 选择或新建一个项目 3. 进入 `APIs & Services > Credentials` 4. 点击 `Create Credentials` 5. 选择 `OAuth client ID` 6. 如果系统要求,先完成 `OAuth consent screen` 7. 应用类型选择 `Web application` 8. 配置允许的来源和回调地址 关键字段这样填: - `Authorized JavaScript origins`:你的域名地址,例如 `https://yourdomain.com` - `Authorized redirect URIs`:`https://yourdomain.com/api/auth/callback/google` 例如你的服务端地址是: ```bash SERVER_URL=https://server.yourdomain.com ``` 那么这里就应该填写: - `Authorized JavaScript origins`:你的域名地址,例如 `https://server.yourdomain.com` - `Authorized redirect URIs`:`https://server.yourdomain.com/api/auth/callback/google` 开发环境下,`easystarter` 默认服务端地址是 `http://localhost:3001`,所以这里通常填写: - `Authorized JavaScript origins`:http://localhost:3001 - `Authorized redirect URIs`:http://localhost:3001/api/auth/callback/google 创建完成后你会拿到: - `Client ID` -> 对应 `GOOGLE_CLIENT_ID` - `Client Secret` -> 对应 `GOOGLE_CLIENT_SECRET` ### 填入环境变量 本地开发建议统一放到 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` 生产部署时,敏感值放到 `apps/server/.env.production`: ```bash title="apps/server/.env.production" GITHUB_CLIENT_SECRET=your-github-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret ``` 然后在 `apps/server/wrangler.jsonc` 的 `vars` 中,把非敏感的 `GITHUB_CLIENT_ID` 和 `GOOGLE_CLIENT_ID` 填进去: ```json title="apps/server/wrangler.jsonc" "vars": { "GITHUB_CLIENT_ID": "your-github-client-id", "GOOGLE_CLIENT_ID": "your-google-client-id" } ``` ## 扩展更多登录方式 如果后续你要扩展更多登录方式,例如 Apple、Discord、GitLab,通常也是继续在 `apps/server/src/lib/auth.ts` 中追加 `socialProviders` 配置。 # Cloudflare (http://page.easystarter.dev/docs/web/integrations/cloudflare) ## Cloudflare 集成 EasyStarter 的服务端运行在 Cloudflare 体系上,核心会用到: - Cloudflare Workers - Cloudflare D1 - Cloudflare R2 如果你要执行数据库迁移、部署服务端,或者配置对象存储,通常都需要先准备 Cloudflare 相关凭据。 ## 所需环境变量 ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` - `CLOUDFLARE_ACCOUNT_ID`:Cloudflare 账号 ID - `CLOUDFLARE_API_TOKEN`:访问 Cloudflare API 的令牌 这些值通常用于 `apps/server/drizzle.config.ts`,让 `drizzle-kit` 通过 D1 HTTP 驱动执行数据库命令。 ## 获取 `CLOUDFLARE_ACCOUNT_ID` 官方文档:[Find account and zone IDs](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/) ### 方式一:从 Account Home 获取 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `Account Home`。 3. 找到你的账号那一行。 4. 点击右侧菜单按钮。 5. 选择 `Copy account ID`。 复制出来的值就是 `CLOUDFLARE_ACCOUNT_ID`。 ### 方式二:从 Workers & Pages 获取 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `Workers & Pages`。 3. 在 `Account details` 区域找到 `Account ID`。 4. 点击复制。 ## 获取 `CLOUDFLARE_API_TOKEN` 官方文档:[Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) 推荐使用 API Token,不要使用旧的 Global API Key。 ### 创建步骤 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 2. 进入 `My Profile > API Tokens`。 3. 点击 `Create Token`。 4. 选择 `Custom token`。 5. 给 token 起一个清晰的名字,比如 `easystarter-d1-migrate`。 6. 在权限里添加: - `Account` -> `D1` -> `Edit` - `Account` -> `Workers R2 Storage` -> `Edit` - `Account` -> `Workers Scripts` -> `Edit` 7. 在资源范围里,只选择当前项目所在的账号。 8. 点击 `Continue to summary`。 9. 检查权限和资源范围。 10. 点击 `Create Token`。 11. 复制生成出来的 token。 复制出来的值就是 `CLOUDFLARE_API_TOKEN`。 ### 注意 - token 只会在创建成功时展示一次 - 丢了就只能重新生成,不能回看明文 - 这个值是敏感信息,只放到 `.dev.vars`、`.env.production` 或 CI secrets 中 ## 放入位置 这些环境变量要位于 `apps/server` 目录下,并命名为 `.dev.vars` 或 `.env.production`。 ```bash title="apps/server/.dev.vars" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` ```bash title="apps/server/.env.production" CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= ``` # 积分系统 (http://page.easystarter.dev/docs/web/integrations/credits) ## 积分系统 EasyStarter 内置一套由服务端账本支撑的积分系统。在 Web 端,用户通过 Web 支付商(Stripe 或 Creem)购买积分包: - **售卖积分包**:走 Stripe / Creem Checkout - **注册赠送积分**,可选过期时间 - **防薅羊毛**:注册赠送需邮箱验证,并按邮箱、IP、User-Agent 限流 - **按功能消耗积分**,幂等且并发安全 - **开箱即用的 UI**:余额入口、购买页、流水记录 账本是唯一数据源。客户端永远不会直接修改余额——只有支付 webhook(购买入账)和服务端消耗会写入账本。 > 同时也在做 App?积分共用同一套服务端和配置——App 端的配置见 [Mobile · 积分系统](/docs/mobile/integrations/credits)。 ## 架构 | 层 | 位置 | | --- | --- | | 配置 | `packages/app-config/src/app-config.ts` | | 服务端 | `apps/server/src/credits/*` | | API 路由 | `apps/server/src/routers/common/credits.ts`、`apps/server/src/routers/web/credits.ts` | | Web UI | `apps/web/src/routes/_authed/(dashboard)/credits/*`、`apps/web/src/hooks/use-credits.ts` | 积分相关数据表: | 表 | 用途 | | --- | --- | | `credit_account` | 用户当前余额和累计值 | | `credit_transaction` | 不可变流水;`(sourceProvider, sourceType, sourceId)` 元组即幂等键 | | `credit_order` | Web 购买订单生命周期 | | `credit_signup_grant_claim` | 注册赠送的资格与防滥用校验 | ## 1. 配置积分包 所有配置都在 `packages/app-config/src/app-config.ts`。 ### 开启 Web 积分 ```ts title="packages/app-config/src/app-config.ts" web: { credits: { enabled: true, signupGrant: creditSignupGrant, packages: webCreditPackages, }, }, ``` ### 配置注册赠送 新用户首次读取余额时赠送积分。`expiresInDays: null`(或省略)表示永不过期。 ```ts title="packages/app-config/src/app-config.ts" const creditSignupGrant = { enabled: true, amount: 100, // 注册赠送的积分数 expiresInDays: 30, // null = 永不过期 } satisfies NonNullable; ``` ### 创建一次性购买产品 积分包是**一次性付款**,绝不是订阅。先在支付商后台创建产品,再把价格 / 产品 ID 填到下一步。 **Stripe** 1. Dashboard → **Products → Add product**,命名(如 `100 Credits`)。 2. 在 **Pricing** 里选 **One time(一次性)**(不是 Recurring),设置金额和币种,保存。 3. 打开该价格,复制 **Price ID**(`price_xxx`)。 4. 切换 **Test mode** 开 / 关,在两个环境各建一个价格——`test` 和 `prod` 各需要一个 ID。 **Creem** 1. Dashboard → **Products → Create product**,命名(如 `100 Credits`)。 2. 计费类型选 **One time**,设置金额。 3. 保存并复制 **Product ID**(`prod_xxx`)——Creem 用 Product ID 作为价格 id。 4. 在测试和正式两个环境各建一次。 > 支付商的完整配置(API 密钥、webhook)见 [Stripe](/docs/web/integrations/payments/stripe) 和 [Creem](/docs/web/integrations/payments/creem) 文档。 ### 配置 Web 积分包 把上一步的价格 / 产品 ID 填进每个积分包的 `web`。运行时按 `NODE_ENV` 自动选用环境。 ```ts title="packages/app-config/src/app-config.ts" const webCreditPackages = [ { id: "starter", // 内部积分包 id amount: 100, // 购买后到账的积分数 web: { provider: "stripe", // "stripe" | "creem" test: { providerPriceId: "price_xxx" }, prod: { providerPriceId: "price_xxx" }, currency: "usd", amountCents: 499, // $4.99 status: "active", }, }, ] satisfies AppCreditsConfig["packages"]; ``` ### 补充积分包文案 为每个积分包 `id` 添加标题和描述,购买 UI 才能正确渲染。 ```jsonc title="packages/i18n/src/messages/web/zh.json" "credits": { "packages": { "starter": { "title": "入门积分包", "description": "{count} 积分,适合轻量使用。" } } } ``` **配置规则** - `amount` 与 `amountCents` 必须是正整数。 - `test` 和 `prod` 价格 ID 都必须填写,且各自唯一。 - 用 `status: "archived"` 可隐藏积分包而不删除历史记录。 - 若同一个积分包也在 App 售卖,复用相同的 `id`(`amount` 和 `status` 须一致)——见 [Mobile · 积分系统](/docs/mobile/integrations/credits)。 ## 2. 准备服务端 ### 执行迁移 ```bash pnpm db:migrate:local # 本地 D1 pnpm db:migrate # 远程 D1 ``` ### 配置支付密钥 积分复用 Web 支付商,除了 [Stripe](/docs/web/integrations/payments/stripe) / [Creem](/docs/web/integrations/payments/creem) 已要求的密钥外,无需额外配置: | 支付商 | 密钥 | | --- | --- | | Stripe | `STRIPE_SECRET_KEY`、`STRIPE_WEBHOOK_SECRET` | | Creem | `CREEM_API_KEY`、`CREEM_WEBHOOK_SECRET` | ### 确认维护定时任务 `apps/server/src/index.ts` 会按 `apps/server/wrangler.jsonc` 中的每日计划运行 `runCreditMaintenance`,用于过期赠送积分、清理过期的待支付订单。 ```jsonc title="apps/server/wrangler.jsonc" "triggers": { "crons": ["10 16 * * *"] } ``` ## 3. 消耗积分 消耗积分是你需要接入到自己业务里的部分。优先在**服务端路由**中调用,避免客户端绕过余额校验。 ```ts title="服务端路由" await context.credits.consumeCredits({ user: { userId: context.session.user.id }, amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` 从浏览器则直接调用 API: ```ts await orpc.credits.consume.call({ amount: 1, idempotencyKey: `image-generate:${recordId}`, metadata: { feature: "image-generate", recordId }, }); ``` > `idempotencyKey` 必须对应一次真实的消耗事件(8–120 字符)。用相同 key 重试只会返回当前余额,不会重复扣费。消耗时优先扣最快过期的积分;余额不足时抛出 `Insufficient credits`。 ## 账本规则 - 注册赠送积分在首次读取余额 / 流水或消耗时懒发放。需邮箱已验证,并按邮箱、IP、User-Agent 限流。 - 购买的积分永不过期(`expiresAt = null`)。 - 赠送积分按 `expiresInDays` 过期,由每日定时任务清理。 - 消耗时优先扣最快过期的积分,再扣永久付费积分。 - 退款只回收原购买记录中尚未消耗的剩余额度。 # 数据库 (http://page.easystarter.dev/docs/web/integrations/database) ## 数据库 项目基于 [Drizzle ORM](https://orm.drizzle.team/) + [Cloudflare D1](https://developers.cloudflare.com/d1/) 构建数据库层。 ### 创建 D1 数据库 参考官方文档:[D1 Getting started](https://developers.cloudflare.com/d1/get-started/) · [Wrangler commands](https://developers.cloudflare.com/d1/wrangler-commands/) 方式一:通过 Cloudflare Dashboard 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. 进入 **Storage & databases → D1 SQL database** 3. 点击 **Create database** 4. 输入数据库名,例如 `easysaas-db` 5. 按需选择地域 6. 点击 **Create** 创建完成后,在数据库详情页复制 `database_id`。 方式二:通过 Wrangler CLI ```bash pnpm wrangler d1 create your-d1-database-name ``` 命令执行成功后会输出 D1 绑定配置,其中包含 `database_id`。 ### 配置 D1 数据库 ID 拿到 `database_id` 后,需要填入以下两个位置。 环境变量文件: ```bash CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= CLOUDFLARE_D1_DATABASE_ID= ``` `CLOUDFLARE_ACCOUNT_ID` 和 `CLOUDFLARE_API_TOKEN` 的获取方式见 [Cloudflare 集成](/docs/web/integrations/cloudflare)。 把 `database_id` 填到: ```bash title="apps/server/.dev.vars" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` 和: ```bash title="apps/server/.env.production" CLOUDFLARE_D1_DATABASE_ID=your-d1-database-id ``` Wrangler 配置: ```json "d1_databases": [ { "binding": "DB", "database_name": "your-d1-database-name", "database_id": "your-d1-database-id" } ] ``` 也就是说,`apps/server/wrangler.jsonc` 里的 `database_id` 也要填成同一个值。 ### 执行本地数据库开发命令 本地数据库开发只需要按这个顺序执行: ```bash pnpm db:generate pnpm db:migrate:local pnpm db:studio:local ``` `pnpm db:generate` 根据 `apps/server/src/db/schema` 生成迁移文件。 `pnpm db:migrate:local` 把刚生成的迁移应用到本地 D1 数据库。这个命令会自动处理本地 D1 初始化。 `pnpm db:studio:local` 打开本地 D1 的可视化界面,检查表结构和数据是否正确。 # Cloudflare 邮件服务 (http://page.easystarter.dev/docs/web/integrations/email) ## Cloudflare 邮件服务 Cloudflare Email Service 适合已经把域名托管在 Cloudflare 的项目。配置完成后,用户可以在自己的邮箱中收到注册验证、密码重置和邮箱验证码邮件。 Cloudflare 邮件服务不需要额外的邮件 API Key,也不需要新增邮件环境变量。 ## 选择 Cloudflare 邮件服务 在 `packages/app-config/src/app-config.ts` 中将邮件服务商切换为 `cloudflare`,并将 `yourdomain.com` 替换为你自己的发件域名: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "cloudflare", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ## 本地开发接收邮件 本地开发时,邮件 HTML 会记录到 Wrangler 日志和临时目录。打开项目下的 `.wrangler/tmp/email` 目录,找到临时 HTML 文件并点击打开,即可查看邮件内容。 ## 线上开启邮件发送 ### 开通 Email Sending 1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/) 2. 进入 **Compute → Email Service → Email Sending** 3. 点击 **Onboard Domain**,选择你的发件域名 4. **验证并激活** 如果域名本身就在 Cloudflare 管理,所需记录通常可以直接在控制台中完成配置。向任意真实用户邮箱发送邮件需要开通 [Workers Paid 套餐](https://developers.cloudflare.com/email-service/platform/pricing/)。 ### 部署并收取测试邮件 正常部署服务端即可。线上 Worker 会直接连接真实的 Cloudflare 邮件服务。 部署完成后,使用一个真实邮箱触发注册验证、忘记密码或邮箱验证码。收到邮件即表示线上邮件发送已经生效;如果暂时没有看到,请检查垃圾邮件目录,并在 Cloudflare Email Sending 页面查看活动日志。 # Resend 邮件服务 (http://page.easystarter.dev/docs/web/integrations/email/resend) ## Resend 邮件服务 Resend 通过 API Key 发送邮件。配置完成后,用户可以在自己的邮箱中收到注册验证、密码重置和邮箱验证码邮件。 ## 线上开启邮件发送 ### 注册 Resend 并获取 API Key 1. 前往 [resend.com](https://resend.com/) 注册账号 2. 登录后进入 [API Keys](https://resend.com/api-keys) 页面 3. 点击 **Create API Key** 4. 权限选择 **Sending access** 5. 创建后立即复制 API Key API Key 以 `re_` 开头,并且只会展示一次,请妥善保存。 ### 验证发件人域名 1. 在 Resend 中进入 **Domains** 页面 2. 点击 **Add Domain**,填写你的发件域名 3. 按页面提示把 DNS 记录添加到域名服务商 4. 回到 Resend,点击 **Verify DNS Records** 5. 等待域名状态验证通过 验证通过后,可以使用 `noreply@yourdomain.com` 这样的地址发送邮件。 ### 填写线上配置 把 `RESEND_API_KEY` 填入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 然后在 `packages/app-config/src/app-config.ts` 中选择 Resend,并填写已经验证的域名: ```ts title="packages/app-config/src/app-config.ts" email: { provider: "resend", from: { localPart: "noreply", domain: "yourdomain.com", }, }, ``` ### 部署并收取测试邮件 推送生产环境变量并正常部署服务端。部署完成后,使用一个真实邮箱触发注册验证、忘记密码或邮箱验证码。 收到邮件即表示线上邮件发送已经生效;如果暂时没有看到,请检查垃圾邮件目录和 Resend Logs。 ## 本地调试并收到真实邮件 把同一个 `RESEND_API_KEY` 填入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 确认邮件服务商为 `resend`,重启本地服务端,然后触发注册验证、忘记密码或邮箱验证码。邮件会直接发送到你填写的测试邮箱。 Resend 测试阶段可以先向自己的 Resend 注册邮箱发送邮件;向其他用户发送前,需要先完成发件域名验证。如果没有收到,请检查垃圾邮件目录和 Resend Logs。 # 在线客服 (http://page.easystarter.dev/docs/web/integrations/live-chat) ## 在线客服 EasyStarter 用 [tawk.to](https://www.tawk.to/) 来作为在线客服接入工具。 ## 获取 ID ### 从 Tawk.to 复制 Property ID 和 Widget ID 1. 在 [tawk.to](https://www.tawk.to/) 注册免费账号并登录 2. 选择对应站点(Property);没有则创建一个 ![tawk.to 仪表盘左上角切换 Property](/images/docs/tawkto/select-property.png) 3. 点击左侧 **Administration** ![tawk.to 左侧栏 Administration 齿轮入口](/images/docs/tawkto/administration.png) 4. 在 **Overview** 中,Property Image 下方就是 **Property ID** ![Overview 页的 Property ID 字段](/images/docs/tawkto/property-id.png) 5. 打开 **Channels → Chat Widget** ![Administration 子菜单中的 Chat Widget](/images/docs/tawkto/chat-widget-menu.png) 6. **Widget ID** 在 **Widget Status** 下方 ![Chat Widget 页 Widget Status 下方的 Widget ID](/images/docs/tawkto/widget-id.png) 也可以从 **Direct Chat Link** 复制 `https://tawk.to/chat/` 后面的两段,或从 **Widget Code** 的脚本地址取出,形如 `https://embed.tawk.to/{PROPERTY_ID}/{WIDGET_ID}` ![Direct Chat Link 中的 Property ID 和 Widget ID](/images/docs/tawkto/direct-chat-link.png) ### 填入环境变量 两个值都必须填写。缺一个就不会加载小组件。 这些是公开的 `VITE_` 变量,会打进客户端包。不要放进 `apps/server/.dev.vars`。 **本地开发**(`apps/web/.env.development`): ```bash title="apps/web/.env.development" VITE_TAWK_PROPERTY_ID=xxxxxxx VITE_TAWK_WIDGET_ID=xxxxxxx ``` **本地生产部署**(`apps/web/wrangler.jsonc` 的 `vars` 字段,明文,无需 Secret): ```jsonc title="apps/web/wrangler.jsonc" { "vars": { "VITE_TAWK_PROPERTY_ID": "xxxxxxx", "VITE_TAWK_WIDGET_ID": "xxxxxxx", // ... } } ``` **CI / 远程部署变量清单**(`apps/web/.env.production`): ```bash title="apps/web/.env.production" VITE_TAWK_PROPERTY_ID=xxxxxxx VITE_TAWK_WIDGET_ID=xxxxxxx ``` # Creem 支付 (http://page.easystarter.dev/docs/web/integrations/payments/creem) ## Creem 支付集成 EasyStarter 的 Web 端内置了完整的 [Creem](https://creem.io/) 支付支持,**仅适用于 Web 端**,开箱即用,包含: - 订阅制(月付 / 年付)结账 - 一次性买断(Lifetime)结账 - 免费试用期(Trial) - 客户账单管理门户(用户自助管理订阅) - 计划升级(支持按比例折算) - Webhook 事件处理(订阅状态同步、退款、争议等) 支付配置分为两部分: 1. **环境变量**:API 密钥与 Webhook 密钥,填入服务端 `.dev.vars` / `.env.production` 2. **定价计划**:在 `packages/app-config/src/app-config.ts` 中配置 Creem Product ID 与价格信息 ## 所需环境变量 ```bash CREEM_API_KEY= CREEM_WEBHOOK_SECRET= ``` ### 注册 Creem 并获取 API 密钥 1. 前往 [creem.io](https://creem.io/) 注册账号 2. 登录后进入控制台的 **API Keys** 页面 3. 复制 **API 密钥**(以 `creem_test_` 开头为测试密钥,`creem_live_` 为生产密钥) > 初始阶段使用测试密钥即可。上线前切换为生产密钥。 将复制的密钥填入: ```bash title="apps/server/.dev.vars" CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" CREEM_API_KEY=creem_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` EasyStarter 会根据密钥前缀自动路由 API 请求: - `creem_test_` → `https://test-api.creem.io/v1` - `creem_live_` → `https://api.creem.io/v1` ### 在 Creem 创建产品 EasyStarter 默认包含三个计划:**Free**、**Pro**(月付 + 年付)、**Lifetime**(一次性买断)。 **Free** 计划无需在 Creem 创建,仅在本地配置中用作占位。以下为 Pro 和 Lifetime 的创建步骤: 1. 在 Creem 控制台进入 **Products** 页面,点击 **Create product** 2. 填写产品名称,例如 `Pro Monthly` 3. **订阅计划(月付 / 年付)**:计费类型选 **Recurring**,分别设置月付周期和年付周期,每个周期各建一个产品 4. **买断计划(Lifetime)**:计费类型选 **One time**,建一个产品 5. 每个产品保存后,复制其 **Product ID**(格式为 `prod_xxxxxxx`),下一步将用到 ### 配置定价计划 将上一步获取的 Product ID 填入 `packages/app-config/src/app-config.ts` 的 `web.payments.plans` 字段: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "creem", // test/prod 分别配置不同环境的 Creem Product ID。 // creem_test_ 使用测试产品 ID,creem_live_ 使用生产产品 ID。 plans: [ { id: "free", // 免费计划,无需 Product ID }, { id: "pro", prices: [ { id: "monthly", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 测试环境 Creem 月付 Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 生产环境 Creem 月付 Product ID }, currency: "usd", amountCents: 1000, // $10.00 priceType: "subscription", interval: "month", trialDays: 7, // 必须与 Creem 产品中设置的试用天数一致 status: "active", }, { id: "yearly", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 测试环境 Creem 年付 Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 生产环境 Creem 年付 Product ID }, currency: "usd", amountCents: 10000, // $100.00 priceType: "subscription", interval: "year", trialDays: 7, // 必须与 Creem 产品中设置的试用天数一致 status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "creem", test: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 测试环境 Creem 一次性 Product ID }, prod: { providerPriceId: "prod_xxxxxxxxxxxxxxxx", // 生产环境 Creem 一次性 Product ID }, currency: "usd", amountCents: 20000, // $200.00 priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` 字段说明: | 字段 | 说明 | | --- | --- | | `test.providerPriceId` | Creem 测试环境的 Product ID,格式 `prod_xxx` | | `prod.providerPriceId` | Creem 生产环境的 Product ID,格式 `prod_xxx` | | `amountCents` | 价格(分),`1000` = $10.00 | | `priceType` | `"subscription"` 订阅 / `"lifetime"` 一次性 | | `interval` | 订阅周期:`"month"` / `"year"`(lifetime 不填)| | `trialDays` | 必须与 Creem 产品中设置的试用天数保持一致 —— Creem 不支持通过 API 设置试用期,此字段仅用于前端展示,实际试用天数以 Creem 控制台产品配置为准 | | `status` | `"active"` 启用 / `"archived"` 归档(不显示在定价页)| ### 配置 Creem Webhook Webhook 用于接收 Creem 推送的事件(如支付成功、订阅变更、退款等),是支付状态同步的核心机制。 1. 在 Creem 控制台进入 **Webhooks** 页面 2. 点击 **Add endpoint** 3. 填写 **Endpoint URL**: - 本地开发:`https://your-ngrok-url/api/webhooks/creem`(需使用 [ngrok](https://ngrok.com/) 等隧道工具) - 生产环境:`https://your-server.workers.dev/api/webhooks/creem` 4. 在 **Events to send** 中选择以下事件(EasyStarter 已处理): | 事件 | 说明 | | --- | --- | | `checkout.completed` | 结账完成(订阅或一次性) | | `subscription.active` | 订阅激活 | | `subscription.trialing` | 试用开始 | | `subscription.paid` | 续费成功 | | `subscription.scheduled_cancel` | 到期取消已安排 | | `subscription.past_due` | 付款逾期 | | `subscription.update` | 订阅变更 | | `subscription.expired` | 订阅到期 | | `subscription.canceled` | 订阅取消 | | `subscription.paused` | 订阅暂停 | | `refund.created` | 退款处理 | | `dispute.created` | 争议 / 拒付创建 | 5. 保存后,复制 **Webhook Secret** 填入: ```bash title="apps/server/.dev.vars" CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` > Creem 使用 HMAC-SHA256 签名 Webhook 负载,EasyStarter 会自动从 `creem-signature` 请求头验证签名。 ### 填入环境变量并启动 确认两个变量已在开发环境中填写完整: ```bash title="apps/server/.dev.vars" CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 然后启动服务: ```bash pnpm dev:server ``` > 由于 Creem 没有像 Stripe CLI 那样的本地转发工具,本地测试 Webhook 需要使用隧道服务(如 [ngrok](https://ngrok.com/)): > ```bash > ngrok http 3001 > ``` > 然后将 ngrok URL 设置为 Creem 控制台中的 Webhook 端点。 ## 定价路由配置 支付成功 / 取消后的跳转页面在 `packages/app-config/src/app-config.ts` 的 `web.routes` 中配置: ```ts title="packages/app-config/src/app-config.ts" web: { routes: { billingSuccess: "/billing/success", // 支付成功后跳转 billingCancel: "/billing/cancel", // 取消支付后跳转 billingReturn: "/settings/billing", // 账单管理门户返回后跳转 }, }, ``` ## 客户账单管理门户 EasyStarter 已内置 Creem 客户账单管理门户集成。用户可在 `/settings/billing` 页面点击管理订阅,系统会自动通过 Creem API 创建管理会话并跳转至 Creem 提供的管理界面。 ## 订阅状态映射 Creem 的订阅状态比 Stripe 更细粒度。以下是 Creem 状态到 EasyStarter 内部状态的映射关系: | Creem 状态 | 内部状态 | 有权限 | 说明 | | --- | --- | --- | --- | | `active` | active | 是 | 正常活跃订阅 | | `trialing` | trialing | 是 | 免费试用期 | | `scheduled_cancel` | active (cancelAtPeriodEnd) | 是 | 到期前仍可使用 | | `paused` | paused | 否 | 订阅已暂停 | | `past_due` | unpaid | 否 | 付款逾期 | | `unpaid` | unpaid | 否 | 未付款 | | `incomplete` | unpaid | 否 | 设置未完成 | | `failed` | unpaid | 否 | 支付失败 | | `canceled` | canceled | 否 | 订阅已取消 | | `expired` | canceled | 否 | 订阅已过期 | ## 扩展其他支付服务商 EasyStarter 的支付层基于 `PaymentProvider` 接口设计,内置 Stripe 和 Creem 实现。如需替换为其他服务商(如 [Paddle](https://www.paddle.com/)、[LemonSqueezy](https://www.lemonsqueezy.com/) 等),只需按以下六步操作,无需改动业务逻辑。 ### 第一步:注册服务商 Key 在 `packages/app-config/src/types.ts` 中,将新服务商 key 追加到 `SUPPORTED_WEB_PAYMENT_PROVIDERS`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "creem", "paddle"] as const; ``` 这会自动同步更新 `WebPaymentProviderKey` 和 `ServerPaymentProviderKey` 类型。 ### 第二步:实现 PaymentProvider 接口 在 `apps/server/src/payments/providers/` 下新建目录,实现 `PaymentProvider` 接口: ```ts title="apps/server/src/payments/providers/paddle/provider.ts" import type { CreateCheckoutInput, CreatePortalInput, ParsedWebhookEvent, PaymentProvider, WebhookInput, } from "../../public/types"; export function createPaddlePaymentProvider(): PaymentProvider { return { key: "paddle", async createCheckoutSession(input: CreateCheckoutInput) { // 调用 Paddle SDK 创建结账会话 // 返回 { providerSessionId, url, expiresAt } }, async createPortalSession(input: CreatePortalInput) { // 返回 Paddle 订阅管理页面的 URL // 返回 { providerSessionId, url } }, async parseWebhookEvent(input: WebhookInput): Promise { // 验证签名,解析 payload // 返回 { providerEventId, type, createdAt, payload } }, async setSubscriptionCancelAtPeriodEnd(input) { // 调用 Paddle API 设置到期取消 }, async updateSubscriptionPrice(input) { // 调用 Paddle API 升级订阅价格 }, }; } ``` 接口方法说明: | 方法 | 说明 | | --- | --- | | `createCheckoutSession` | 创建结账会话,返回跳转 URL | | `createPortalSession` | 创建订阅管理门户会话,返回跳转 URL | | `parseWebhookEvent` | 验证 Webhook 签名并解析事件 | | `setSubscriptionCancelAtPeriodEnd` | 设置订阅到期时取消 | | `updateSubscriptionPrice` | 变更订阅价格(用于计划升级) | ### 第三步:实现 Webhook 事件处理器 在 `apps/server/src/payments/providers/paddle/webhook/` 下创建事件处理逻辑,将 Paddle 事件映射到数据库操作: ```ts title="apps/server/src/payments/providers/paddle/webhook/handle-event.ts" import type { Database } from "@/db"; export async function handlePaddleEvent(db: Database, payload: unknown) { const event = payload as { event_type: string; data: unknown }; switch (event.event_type) { case "subscription.created": case "subscription.updated": case "subscription.canceled": { // 同步订阅状态到 billing_subscription 表 break; } case "transaction.completed": { // 处理一次性购买,写入 billing_purchase 表 break; } // 按需处理其他事件... default: break; } } ``` > 参考 `apps/server/src/payments/providers/stripe/webhook/` 目录的结构,将复杂事件拆分到独立文件中。 ### 第四步:注册到 Provider 工厂 在 `apps/server/src/payments/providers/index.ts` 中,将新 Provider 注册进工厂: ```ts title="apps/server/src/payments/providers/index.ts" import { createPaddlePaymentProvider } from "./paddle/provider"; const providers: Record PaymentProvider> = { stripe: createStripePaymentProvider, creem: createCreemPaymentProvider, paddle: createPaddlePaymentProvider, // 新增 }; ``` ### 第五步:添加 Webhook 路由 在 `apps/server/src/index.ts` 中,为新服务商添加一个专属 Webhook 路由: ```ts title="apps/server/src/index.ts" app.post("/api/webhooks/paddle", async (c) => { const context = await createContext({ context: c }); const rawBody = await c.req.text(); const signature = c.req.header("paddle-signature"); await context.payments.handleWebhookEvent({ provider: "paddle", rawBody, signature, }); return c.json({ received: true }); }); ``` 服务端的 `handleWebhookEvent` 会自动将事件路由到对应的 `handlePaddleEvent` 处理器。 ### 第六步:配置定价计划并切换 Provider 在 `packages/app-config/src/app-config.ts` 中,将 `web.payments.provider` 改为新服务商,并填入对应的 Price ID: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "paddle", // 切换到新服务商 plans: [ { id: "pro", prices: [ { id: "monthly", provider: "paddle", providerPriceId: "pri_xxxxxxxxxxxxxxxx", // Paddle Price ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, ``` 完成后,所有结账、升级、门户入口都会自动走新服务商,无需改动任何业务代码。 ## 生产上线前检查 | 项目 | 检查点 | | --- | --- | | API 密钥 | 切换为生产密钥 `creem_live_` | | Webhook Secret | 生产 Webhook 端点的签名密钥 | | Product ID | 使用生产模式下创建的 Product ID | | Webhook 端点 | 生产 URL 已在 Creem 控制台配置 | | 环境变量推送 | 通过 `pnpm run secrets:bulk:production` 推送到 Cloudflare Workers | # Stripe 支付 (http://page.easystarter.dev/docs/web/integrations/payments/stripe) ## Stripe 支付集成 EasyStarter 的 Web 端内置了完整的 [Stripe](https://stripe.com/) 支付支持,开箱即用,包含: - 订阅制(月付 / 年付)结账 - 一次性买断(Lifetime)结账 - 免费试用期(Trial) - Stripe Billing Portal(用户自助管理订阅、取消、更换支付方式、查看发票) - 计划升级(月付 → 年付,直接在后台完成,无需跳转 Checkout) - Webhook 事件处理(订阅状态同步、发票、退款、争议等) 支付配置分为两部分: 1. **环境变量**:API 密钥与 Webhook 签名密钥,填入服务端 `.dev.vars` / `.env.production` 2. **定价计划**:在 `packages/app-config/src/app-config.ts` 中配置 Stripe Price ID 与价格信息 ## 所需环境变量 ```bash STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= ``` ### 注册 Stripe 并获取 API 密钥 官方文档:[Stripe API Keys](https://dashboard.stripe.com/apikeys) 1. 前往 [stripe.com](https://stripe.com/) 注册账号并完成邮箱验证 2. 登录后进入 **[Developers → API keys](https://dashboard.stripe.com/apikeys)** 3. 复制 **Secret key**(以 `sk_test_` 开头为测试密钥,`sk_live_` 为生产密钥) > 初始阶段使用测试密钥即可。上线前在同一页面切换到 **Live mode** 获取正式密钥。 将复制的密钥填入: ```bash title="apps/server/.dev.vars" STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ```bash title="apps/server/.env.production" STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### 在 Stripe 创建产品与价格 EasyStarter 默认包含三个计划:**Free**、**Pro**(月付 + 年付)、**Lifetime**(一次性买断)。 **Free** 计划无需在 Stripe 创建,仅在本地配置中用作占位。以下为 Pro 和 Lifetime 的创建步骤: 官方文档:[Stripe Products & Prices](https://dashboard.stripe.com/products) 1. 进入 **[Products](https://dashboard.stripe.com/products)** 页面,点击 **Add product** 2. 填写产品名称,例如 `Pro` 3. 在 **Pricing** 区域: - 选择 **Recurring**(订阅),填写价格,选择计费周期(**Monthly** 或 **Yearly**) - 点击 **More price options (更多价格选项)** 可在同一产品下添加多个价格 4. 保存后,点击每个价格的详情,复制 **Price ID**(格式为 `price_xxxxxxx`) 5. 重复以上步骤为 **Lifetime** 创建产品,定价类型选择 **One time**(一次性收费) 每个价格对应一个唯一的 Price ID,下一步将用到。 ### 配置定价计划 将上一步获取的 Price ID 填入 `packages/app-config/src/app-config.ts` 的 `web.payments.plans` 字段: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "stripe", // test/prod 分别配置不同环境的 Stripe Price ID。 // sk_test_ 使用测试模式 price_...,sk_live_ 使用 Live 模式 price_...。 plans: [ { id: "free", // 免费计划,无需 Price ID }, { id: "pro", prices: [ { id: "monthly", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 沙盒测试环境 Stripe 月付 Price ID }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 生产环境 Stripe 月付 Price ID }, currency: "usd", amountCents: 1000, // $10.00 priceType: "subscription", interval: "month", trialDays: 7, // 免费试用天数,不需要则删除此字段 status: "active", }, { id: "yearly", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 沙盒测试环境 Stripe 年付 Price ID }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 生产环境 Stripe 年付 Price ID }, currency: "usd", amountCents: 10000, // $100.00 priceType: "subscription", interval: "year", trialDays: 7, status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "stripe", test: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 沙盒测试环境 Stripe 一次性 Price ID }, prod: { providerPriceId: "price_xxxxxxxxxxxxxxxx", // 生产环境 Stripe 一次性 Price ID }, currency: "usd", amountCents: 20000, // $200.00 priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` 字段说明: | 字段 | 说明 | | --- | --- | | `test.providerPriceId` | Stripe 沙盒测试环境的 Price ID,格式 `price_xxx` | | `prod.providerPriceId` | Stripe 生产环境的 Price ID,格式 `price_xxx` | | `amountCents` | 价格(分),`1000` = $10.00 | | `priceType` | `"subscription"` 订阅 / `"lifetime"` 一次性 | | `interval` | 订阅周期:`"month"` / `"year"`(lifetime 不填)| | `trialDays` | 免费试用天数,不需要则删除此字段 | | `status` | `"active"` 启用 / `"archived"` 归档(不显示在定价页)| ### 配置 Stripe Webhook Webhook 用于接收 Stripe 推送的事件(如支付成功、订阅变更、发票等),是支付状态同步的核心机制。 **本地开发(使用 Stripe CLI):** 1. 安装 [Stripe CLI](https://docs.stripe.com/stripe-cli): ```bash # macOS brew install stripe/stripe-cli/stripe ``` 2. 登录 Stripe CLI: ```bash stripe login ``` 3. 启动本地 Webhook 转发(服务端默认监听 `http://localhost:3001`): ```bash stripe listen --forward-to http://localhost:3001/api/webhooks/stripe ``` 4. 启动后终端会输出一个 **Webhook signing secret**,格式以 `whsec_` 开头,将其填入: ```bash title="apps/server/.dev.vars" STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` **生产环境(Stripe Dashboard):** 1. 进入 **[Developers → Webhooks](https://dashboard.stripe.com/webhooks)** 2. 点击 **Add endpoint** 3. **Endpoint URL** 填写你的生产地址:`https://your-server.workers.dev/api/webhooks/stripe` 4. 在 **Events to send** 选择以下事件(EasyStarter 已处理): | 事件 | 说明 | | --- | --- | | `checkout.session.completed` | 结账完成 | | `checkout.session.async_payment_succeeded` | 异步支付成功 | | `checkout.session.async_payment_failed` | 异步支付失败 | | `checkout.session.expired` | 结账会话过期 | | `customer.subscription.created` | 订阅创建 | | `customer.subscription.updated` | 订阅变更 | | `customer.subscription.deleted` | 订阅取消 | | `payment_intent.succeeded` | 支付意图成功 | | `payment_intent.payment_failed` | 支付意图失败 | | `payment_intent.canceled` | 支付意图取消 | | `invoice.paid` | 发票支付成功 | | `invoice.payment_failed` | 发票支付失败 | | `invoice.marked_uncollectible` | 发票标记为无法收取 | | `invoice.voided` | 发票作废 | | `charge.dispute.created` | 争议创建 | | `charge.dispute.updated` | 争议更新 | | `charge.dispute.closed` | 争议关闭 | | `charge.refunded` | 退款 | 5. 保存后,点击 Webhook 端点详情,复制 **Signing secret**(格式 `whsec_xxx`)填入: ```bash title="apps/server/.env.production" STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ### 填入环境变量并启动 确认两个变量已在开发环境中填写完整: ```bash title="apps/server/.dev.vars" STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 然后启动服务: ```bash pnpm dev:server ``` 同时在另一个终端保持 Stripe CLI 转发运行,即可在本地完整测试支付流程。 > **测试卡号**:Stripe 测试模式下可使用 `4242 4242 4242 4242`,有效期任意未来日期,CVV 任意三位数字。 > 更多测试卡见 [Stripe Testing Docs](https://docs.stripe.com/testing)。 ## 定价路由配置 支付成功 / 取消后的跳转页面在 `packages/app-config/src/app-config.ts` 的 `web.routes` 中配置: ```ts title="packages/app-config/src/app-config.ts" web: { routes: { billingSuccess: "/billing/success", // 支付成功后跳转 billingCancel: "/billing/cancel", // 取消支付后跳转 billingReturn: "/settings/billing", // Billing Portal 返回后跳转 }, }, ``` ## Billing Portal(用户自助管理) EasyStarter 已内置 Stripe Billing Portal 集成。用户可在 `/settings/billing` 页面点击管理订阅,系统会自动创建 Portal 会话并跳转至 Stripe 提供的管理界面。 使用前需要在 Stripe 后台启用并配置 Billing Portal: 1. 进入 **[Customer Portal Settings](https://dashboard.stripe.com/settings/billing/portal)** 2. 按需开启**取消订阅**、**更换支付方式**、**查看发票**等功能 3. 保存配置后即可生效 ## 生产上线前检查 | 项目 | 检查点 | | --- | --- | | API 密钥 | 切换为 Live 模式的 `sk_live_` 密钥 | | Webhook Secret | 生产 Webhook 端点的 `whsec_` 签名密钥 | | Price ID | 使用 Live 模式下创建的 Price ID | | Billing Portal | 已在 Stripe Dashboard 配置并启用 | | 环境变量推送 | 通过 `pnpm run secrets:bulk:production` 推送到 Cloudflare Workers | ## 扩展其他支付服务商 EasyStarter 的支付层基于 `PaymentProvider` 接口设计,内置 Stripe 实现。如需替换为其他服务商(如 [Paddle](https://www.paddle.com/)、[LemonSqueezy](https://www.lemonsqueezy.com/) 等),只需按以下六步操作,无需改动业务逻辑。 ### 第一步:注册服务商 Key 在 `packages/app-config/src/types.ts` 中,将新服务商 key 追加到 `SUPPORTED_WEB_PAYMENT_PROVIDERS`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "paddle"] as const; ``` 这会自动同步更新 `WebPaymentProviderKey` 和 `ServerPaymentProviderKey` 类型。 ### 第二步:实现 PaymentProvider 接口 在 `apps/server/src/payments/providers/` 下新建目录,实现 `PaymentProvider` 接口: ```ts title="apps/server/src/payments/providers/paddle/provider.ts" import type { CreateCheckoutInput, CreatePortalInput, ParsedWebhookEvent, PaymentProvider, WebhookInput, } from "../../public/types"; export function createPaddlePaymentProvider(): PaymentProvider { return { key: "paddle", async createCheckoutSession(input: CreateCheckoutInput) { // 调用 Paddle SDK 创建结账会话 // 返回 { providerSessionId, url, expiresAt } }, async createPortalSession(input: CreatePortalInput) { // 返回 Paddle 订阅管理页面的 URL // 返回 { providerSessionId, url } }, async parseWebhookEvent(input: WebhookInput): Promise { // 验证签名,解析 payload // 返回 { providerEventId, type, createdAt, payload } }, async setSubscriptionCancelAtPeriodEnd(input) { // 调用 Paddle API 设置到期取消 }, async updateSubscriptionPrice(input) { // 调用 Paddle API 升级订阅价格 }, }; } ``` 接口方法说明: | 方法 | 说明 | | --- | --- | | `createCheckoutSession` | 创建结账会话,返回跳转 URL | | `createPortalSession` | 创建订阅管理门户会话,返回跳转 URL | | `parseWebhookEvent` | 验证 Webhook 签名并解析事件 | | `setSubscriptionCancelAtPeriodEnd` | 设置订阅到期时取消 | | `updateSubscriptionPrice` | 变更订阅价格(用于计划升级) | ### 第三步:实现 Webhook 事件处理器 在 `apps/server/src/payments/providers/paddle/webhook/` 下创建事件处理逻辑,将 Paddle 事件映射到数据库操作: ```ts title="apps/server/src/payments/providers/paddle/webhook/handle-event.ts" import type { Database } from "@/db"; export async function handlePaddleEvent(db: Database, payload: unknown) { const event = payload as { event_type: string; data: unknown }; switch (event.event_type) { case "subscription.created": case "subscription.updated": case "subscription.canceled": { // 同步订阅状态到 billing_subscription 表 break; } case "transaction.completed": { // 处理一次性购买,写入 billing_purchase 表 break; } // 按需处理其他事件... default: break; } } ``` > 参考 `apps/server/src/payments/providers/stripe/webhook/` 目录的结构,将复杂事件拆分到独立文件中。 ### 第四步:注册到 Provider 工厂 在 `apps/server/src/payments/providers/index.ts` 中,将新 Provider 注册进工厂: ```ts title="apps/server/src/payments/providers/index.ts" import { createPaddlePaymentProvider } from "./paddle/provider"; const providers: Record PaymentProvider> = { stripe: createStripePaymentProvider, paddle: createPaddlePaymentProvider, // 新增 }; ``` ### 第五步:添加 Webhook 路由 在 `apps/server/src/index.ts` 中,为新服务商添加一个专属 Webhook 路由: ```ts title="apps/server/src/index.ts" app.post("/api/webhooks/paddle", async (c) => { const context = await createContext({ context: c }); const rawBody = await c.req.text(); const signature = c.req.header("paddle-signature"); await context.payments.handleWebhookEvent({ provider: "paddle", rawBody, signature, }); return c.json({ received: true }); }); ``` 服务端的 `handleWebhookEvent` 会自动将事件路由到对应的 `handlePaddleEvent` 处理器。 ### 第六步:配置定价计划并切换 Provider 在 `packages/app-config/src/app-config.ts` 中,将 `web.payments.provider` 改为新服务商,并填入对应的 Price ID: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "paddle", // 切换到新服务商 plans: [ { id: "pro", prices: [ { id: "monthly", provider: "paddle", providerPriceId: "pri_xxxxxxxxxxxxxxxx", // Paddle Price ID currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", status: "active", }, ], }, ], }, }, ``` 完成后,所有结账、升级、门户入口都会自动走新服务商,无需改动任何业务代码。 # Waffo 支付 (http://page.easystarter.dev/docs/web/integrations/payments/waffo) ## Waffo Pancake 支付集成 EasyStarter 的 Web 端内置了 [Waffo Pancake](https://pancake.waffo.ai/) 支付支持,**仅适用于 Web 端**,包含: - 订阅制(月付 / 年付)托管结账 - 一次性买断(Lifetime)托管结账 - Waffo 产品试用期 - Waffo 消费者门户 - 到期取消订阅 - Webhook 事件处理(订单、订阅、续费与退款同步) 支付配置分为两部分: 1. **环境变量**:Merchant ID、私钥与运行环境,填入服务端 `.dev.vars` / `.env.production` 2. **定价计划**:在 `packages/app-config/src/app-config.ts` 中配置 Waffo Product ID 与价格信息 > Waffo SDK 只能在服务端使用。切勿把 `WAFFO_PRIVATE_KEY` 写入 Web 环境变量、前端代码或提交到 Git。 ## 所需环境变量 ```bash WAFFO_MERCHANT_ID= WAFFO_PRIVATE_KEY= WAFFO_ENVIRONMENT=test ``` | 变量 | 说明 | | --- | --- | | `WAFFO_MERCHANT_ID` | 商户 ID,格式为 `MER_xxx`;不要填写 Store ID | | `WAFFO_PRIVATE_KEY` | 当前环境的 RSA 私钥,仅供服务端 SDK 签名请求 | | `WAFFO_ENVIRONMENT` | Webhook 验签环境:开发填 `test`,生产填 `prod` | ### 获取 Merchant ID 与测试私钥 1. 登录 [Waffo Pancake 商户后台](https://pancake.waffo.ai/merchant/dashboard/integration) 2. 进入 **集成** 页面,先选择 **测试模式** 3. 复制页面顶部的 **商户 ID**(格式为 `MER_xxx`) 4. 在 **创建 API 密钥** 区域创建测试密钥,复制私钥或直接使用 **复制 .env 配置** 5. 将凭证写入本地服务端环境文件: ```bash title="apps/server/.dev.vars" WAFFO_MERCHANT_ID=MER_xxxxxxxxxxxxxxxxxxxxxxxx WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" WAFFO_ENVIRONMENT=test ``` > Waffo 的测试密钥与生产密钥相互独立。切换后台环境时,必须同步更换私钥与 `WAFFO_ENVIRONMENT`,否则 API 调用或 Webhook 验签会失败。 私钥可以保留后台复制出的 PEM 格式。若 `.env` 使用单行值,请保留引号与 `\n` 转义换行;Waffo SDK 会自动规范化 PEM 内容。 ### 在 Waffo 创建产品 EasyStarter 默认包含三个计划:**Free**、**Pro**(月付 + 年付)与 **Lifetime**(一次性买断)。Free 计划无需创建 Waffo 产品。 在商户后台进入 **产品** 页面,创建以下三个产品: | EasyStarter 价格 | Waffo 产品类型 | Waffo 计费周期 | | --- | --- | --- | | Pro Monthly | Subscription | Monthly | | Pro Yearly | Subscription | Yearly | | Lifetime | One-time | 不适用 | 保存后复制每个产品的 **Product ID**(格式为 `PROD_xxx`)。EasyStarter 将 Waffo Product ID 存放在通用的 `providerPriceId` 字段中。 如需试用期,请在 Waffo 订阅产品或产品组中设置试用天数,并让下一步的 `trialDays` 与后台配置保持一致。 ### 配置定价计划 将 Waffo Product ID 填入 `packages/app-config/src/app-config.ts` 的 `web.payments.plans`: ```ts title="packages/app-config/src/app-config.ts" web: { payments: { provider: "waffo", plans: [ { id: "free", }, { id: "pro", prices: [ { id: "monthly", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test monthly Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production monthly Product ID }, currency: "usd", amountCents: 1000, priceType: "subscription", interval: "month", trialDays: 7, status: "active", }, { id: "yearly", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test yearly Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production yearly Product ID }, currency: "usd", amountCents: 10000, priceType: "subscription", interval: "year", trialDays: 7, status: "active", }, ], }, { id: "lifetime", prices: [ { id: "lifetime", provider: "waffo", test: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Test one-time Product ID }, prod: { providerPriceId: "PROD_xxxxxxxxxxxxxxxx", // Production one-time Product ID }, currency: "usd", amountCents: 20000, priceType: "lifetime", status: "active", }, ], }, ], }, }, ``` 字段说明: | 字段 | 说明 | | --- | --- | | `provider` | Web 默认支付服务商与每个价格的服务商都设为 `"waffo"` | | `test.providerPriceId` | 测试环境可用的 Waffo Product ID,格式为 `PROD_xxx` | | `prod.providerPriceId` | 生产环境已发布的 Waffo Product ID,格式为 `PROD_xxx` | | `amountCents` | EasyStarter 前端展示的金额(分),必须与 Waffo 产品价格一致 | | `priceType` | `"subscription"` 订阅 / `"lifetime"` 一次性买断 | | `interval` | 订阅周期:`"month"` / `"year"`,Lifetime 不填 | | `trialDays` | 非空时结账会请求启用试用;具体天数以 Waffo 产品或产品组配置为准 | | `status` | `"active"` 启用 / `"archived"` 归档 | > Waffo SDK 创建产品时使用 `"10.00"` 这样的展示金额;EasyStarter 的本地配置仍使用分,因此 `$10.00` 应填写 `amountCents: 1000`。 ### 配置 Waffo Webhook Webhook 是订单完成后授予权限以及同步订阅状态的依据,必须配置。 1. 在 Waffo 商户后台进入 **设置 → Webhooks** 2. 添加 **HTTP** Webhook,并选择与当前凭证一致的测试或生产环境 3. 填写 Endpoint URL: - 本地开发:`https://your-ngrok-url/api/webhooks/waffo` - 生产环境:`https://your-server.workers.dev/api/webhooks/waffo` 4. 订阅 EasyStarter 已处理的事件: | 事件 | EasyStarter 行为 | | --- | --- | | `order.completed` | 完成一次性购买或积分包订单 | | `subscription.activated` | 激活订阅 | | `subscription.payment_succeeded` | 同步续费成功 | | `subscription.canceling` | 标记到期取消,当前周期内保留权限 | | `subscription.uncanceled` | 恢复已安排取消的订阅状态 | | `subscription.updated` | 同步订阅变更 | | `subscription.canceled` | 标记订阅已终止 | | `subscription.past_due` | 标记续费逾期 | | `refund.succeeded` | 撤销对应买断或积分权益 | | `refund.failed` | 记录事件,不改变权益状态 | Waffo 使用 RSA-SHA256 为原始请求体签名,并通过 `x-waffo-signature` 请求头发送签名。EasyStarter 会读取原始文本并使用 Waffo SDK 自动验签,不需要单独配置 Webhook Secret。 > 本地调试请使用 [ngrok](https://ngrok.com/) 并转发 Server 的 `3001` 端口。不要使用会移除自定义请求头的隧道,否则 `x-waffo-signature` 丢失后将无法验签。 ```bash ngrok http 3001 ``` ### 启动并完成沙盒结账 启动 Web 与 Server: ```bash pnpm dev:web+server ``` 打开定价页完成一次测试结账。Waffo 结账会在新标签页打开,以保留 EasyStarter 当前页面状态。 | 场景 | 测试卡号 | | --- | --- | | 支付成功 | `4576 7500 0000 0110` | | 支付失败 | `4576 7500 0000 0220` | 有效期可填写任意未来日期,CVC 可填写任意值。支付后确认 Server 收到 `POST /api/webhooks/waffo` 且返回 `200`,再到 `/settings/billing` 检查订阅或买断权益。 ## 生产环境上线 上线前完成以下检查: 1. 在 Waffo 后台切换到 **生产模式**,创建并复制独立的生产私钥 2. 将所需产品发布到生产环境,并确认 `prod.providerPriceId` 对应可用的生产 Product ID 3. 在生产环境注册 `https://your-server.workers.dev/api/webhooks/waffo` 4. 写入生产服务端环境变量: ```bash title="apps/server/.env.production" WAFFO_MERCHANT_ID=MER_xxxxxxxxxxxxxxxxxxxxxxxx WAFFO_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" WAFFO_ENVIRONMENT=prod ``` 5. 按[部署 Server](/docs/web/deploy-server)文档上传生产 Secrets 并部署 不要在生产环境复用测试私钥,也不要在产品尚未发布时填写生产 Product ID。 ## 消费者门户与当前限制 用户可从 `/settings/billing` 前往 [Waffo 消费者门户](https://pancake.waffo.ai/consumer/portal/login)管理订阅。当前 EasyStarter 集成的能力边界如下: - 支持取消订阅,并在当前计费周期结束时失效 - 取消后的恢复操作需由买家在 Waffo 门户完成 - Waffo 的[更换订阅产品接口](https://docs.waffo.ai/zh/api-reference/endpoints/subscriptions/change-product)目前尚未实现,调用固定返回 `501 Not Implemented`,因此 EasyStarter 暂时无法在应用内提供订阅升级或降级;这是 Waffo 平台当前的能力限制 - Waffo Webhook 仍会同步买家在门户完成的恢复和订阅变更 更多平台细节可参考 [Waffo 官方 SDK 集成指南](https://docs.waffo.ai/zh/integrate/skill)与 [Webhook 指南](https://docs.waffo.ai/zh/guides/webhooks)。 # 管理员与 RBAC (http://page.easystarter.dev/docs/web/integrations/rbac) EasyStarter 已经集成全局 RBAC,默认包含 `user` 和 `admin` 两种角色。每个账户只保存一个角色。 ## 配置 RBAC ### 开启管理功能 修改 `packages/app-config/src/app-config.ts` 中的 `appConfig.common`: ```ts common: { admin: { // 开启付费用户管理 paidUsers: { enabled: true, }, // 开启用户管理和管理操作记录 userManagement: { enabled: true, }, }, auth: { // 其他认证配置…… rbac: { defaultRole: "user", adminRoles: ["admin"], }, }, } ``` `userManagement.enabled` 控制用户管理和管理操作记录。`paidUsers.enabled` 控制付费用户管理。关闭后,对应页面和 API 会同时停用。 请保持 `defaultRole: "user"`。如果改成 `"admin"`,每个新账户都会获得管理权限。 ### 配置初始管理员邮箱 `ADMIN_EMAIL` 应填写将要登录管理后台的真实账户邮箱: ```bash title="apps/server/.dev.vars" ADMIN_EMAIL=admin@yourcompany.com ``` 生产环境填写到: ```bash title="apps/server/.env.production" ADMIN_EMAIL=admin@yourcompany.com ``` 然后上传 Cloudflare Secret: ```bash pnpm -F server secrets:bulk:production ``` `ADMIN_EMAIL` 不是发件地址,也不是 `supportEmail`。它必须与登录账户的已验证邮箱一致。 当前只支持填写一个邮箱,不要使用逗号分隔多个地址。第一个管理员登录后,可以在用户管理页为其他账户分配 `admin` 角色。 ### 让管理员角色生效 配置 `ADMIN_EMAIL` 后,重新部署 Server 即可。 如果该用户已经登录过,让其退出后重新登录。Server 会在创建新会话时把该账户的 `role` 更新为 `admin`。 ## 默认权限 | 权限 | 用途 | `user` | `admin` | | ------------------------ | -------------------- | ------ | ------- | | `admin:access` | 进入管理区域 | ✗ | ✓ | | `user:list` | 查看用户 | ✗ | ✓ | | `user:set-role` | 修改用户角色 | ✗ | ✓ | | `user:ban` | 封禁和解封用户 | ✗ | ✓ | | `credits:adjust` | 调整积分 | ✗ | ✓ | | `membership:grant-trial` | 赠送 Membership 试用 | ✗ | ✓ | | `operation:list` | 查看管理操作记录 | ✗ | ✓ | 权限字典和角色矩阵位于 `packages/app-config/src/rbac/index.ts`。 ## Web 端如何使用 RBAC Web 侧边栏会同时检查功能开关和当前用户权限。路由也会在 `beforeLoad` 内再次校验: ```tsx beforeLoad: ({ context }) => { if (!webConfig.adminUserManagementEnabled) { throw notFound(); } if (!hasPermission(context.user.role, "user", "list")) { throw redirect({ to: "/forbidden" }); } }, ``` 前端检查只用于导航和用户体验。服务端仍必须独立检查权限。 ```ts import { assertPermission, protectedProcedure } from "@/lib/orpc"; export const adjustCredits = protectedProcedure.handler(async ({ context }) => { assertPermission(context, "credits", "adjust"); // 业务逻辑 }); ``` 只需要通用管理权限时,可以直接使用 `adminProcedure`。它要求当前账户具备 `admin:access`。 ## 撤销管理员 删除或更换 `ADMIN_EMAIL` 不会自动撤销旧管理员。请先在用户管理页把旧管理员改回 `user`,再更换环境变量。 # 阿里云 OSS 存储(适合中国大陆业务) (http://page.easystarter.dev/docs/web/integrations/storage/aliyun-oss) ## 阿里云 OSS 存储 EasyStarter 内置了阿里云 [对象存储 OSS](https://help.aliyun.com/zh/oss/) 作为存储服务商,可以与默认的 Cloudflare R2 自由切换。服务端通过 OSS REST API V4 签名直接调用,不依赖任何 Node.js SDK,可以在 Cloudflare Workers Runtime 下直接运行。 如果你的服务主要面向中国大陆用户,使用阿里云 OSS 通常能获得更稳定的访问速度和更低的出口流量成本;与上一节的 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 共用同一对 RAM AccessKey,运维上也更简单。 | 项目 | 当前配置 | | --- | --- | | 上传/下载/列举/删除 | OSS REST API V4 + `OSS4-HMAC-SHA256` 签名 | | 服务端 Provider | `apps/server/src/storage/providers/aliyun-oss.ts` | | Provider 注册位置 | `apps/server/src/storage/index.ts` | | 配置开关 | `packages/app-config/src/app-config.ts` 中的 `common.storage.provider` | | 文件访问路径 | `${SERVER_URL}/api/storage/aliyun-oss/`(通过服务端代理,OSS Bucket 无需公开) | EasyStarter 现有的 `avatar` 与 `attachment` 上传类型、文件大小和 MIME 类型限制对所有 Provider 通用,切换到 OSS 后业务代码无需修改。 ## 所需环境变量 ```bash # 与阿里云手机号登录共用同一对 RAM AccessKey ALIBABA_CLOUD_ACCESS_KEY_ID= ALIBABA_CLOUD_ACCESS_KEY_SECRET= # OSS 专属 ALIYUN_OSS_BUCKET= ALIYUN_OSS_REGION= ALIYUN_OSS_ENDPOINT= ``` 变量说明: | 变量 | 含义 | 示例 | | --- | --- | --- | | `ALIBABA_CLOUD_ACCESS_KEY_ID` | RAM 用户 AccessKey ID,服务端调用 OSS 的长期凭证 | `LTAI5tXXXXXXXXXXXXXXXXX` | | `ALIBABA_CLOUD_ACCESS_KEY_SECRET` | RAM 用户 AccessKey Secret,仅创建时显示一次 | `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` | | `ALIYUN_OSS_BUCKET` | OSS Bucket 名称,不含域名 | `your-app-bucket` | | `ALIYUN_OSS_REGION` | Bucket 所在地域 ID,签名时作为 region scope | `cn-hangzhou` | | `ALIYUN_OSS_ENDPOINT` | OSS 访问域名,**不要带 Bucket 前缀和协议头** | `oss-cn-hangzhou.aliyuncs.com` | `ALIYUN_OSS_ENDPOINT` 必须填外网访问域名,例如 `oss-cn-hangzhou.aliyuncs.com`。如果你写成 `your-app-bucket.oss-cn-hangzhou.aliyuncs.com`,Provider 内部会再次拼接一次 Bucket,导致请求路径错误。 如果你已经按照 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 配置过 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,可以直接复用同一对密钥,只需要为同一个 RAM 用户追加 OSS 权限。 ### 开通对象存储 OSS 服务 先确认阿里云账号已经开通对象存储 OSS 服务,并完成实名认证。 产品页面:[阿里云对象存储 OSS](https://www.aliyun.com/product/oss),在页面顶部点击 **立即开通** 即可。 ### 创建 OSS Bucket 官方文档:[创建存储空间](https://help.aliyun.com/zh/oss/user-guide/create-a-bucket-4) 1. 登录 [OSS 控制台](https://oss.console.aliyun.com/) 2. 点击 **Bucket 列表** → **创建 Bucket** 3. 填写 **Bucket 名称**,例如 `your-app-bucket`(全局唯一,3-63 字符,仅小写字母、数字、短横线) 4. 选择 **地域**,例如 `华东1(杭州)`,对应 region ID `cn-hangzhou` 5. **读写权限**保持默认的 **私有**(文件通过服务端代理访问,Bucket 不需要公开) 6. 其它选项按默认即可,点击 **完成创建** 记录以下信息: - Bucket 名称 → `ALIYUN_OSS_BUCKET` - 地域 ID(控制台 Bucket 概览页的 **地域** 字段,例如 `oss-cn-hangzhou` 中的 `cn-hangzhou`)→ `ALIYUN_OSS_REGION` - 外网访问 Endpoint(Bucket 概览页的 **Endpoint(外网访问)** 字段,例如 `oss-cn-hangzhou.aliyuncs.com`)→ `ALIYUN_OSS_ENDPOINT` ### 为 RAM 用户授予 OSS 权限 推荐使用 RAM 用户的 AccessKey,不要直接使用阿里云主账号 AccessKey。如果你已经按照 [阿里云手机号登录](/docs/web/integrations/authentication/aliyun-phone-auth) 创建过 RAM 用户,可以直接给同一个用户追加授权。 1. 登录 [阿里云 RAM 控制台](https://ram.console.aliyun.com/) 2. 进入 **身份管理** → **用户**,选中目标 RAM 用户 3. 点击 **权限管理** → **新增授权** 4. **资源范围**选择 **账号级别**,在 **权限策略** 中搜索并勾选系统策略 **`AliyunOSSFullAccess`**,然后点击 **确认新增授权** ![在 RAM 新增授权弹窗中勾选 AliyunOSSFullAccess 系统策略](/images/docs/aliyun-oss-ram-policy.png) 这是阿里云官方推荐的做法,挂载系统策略后该 RAM 用户即可读写 OSS。 ### 获取或复用 AccessKey 官方文档:[创建 AccessKey](https://help.aliyun.com/zh/ram/user-guide/create-an-accesskey-pair) 如果还没有 AccessKey: 1. 在 RAM 用户详情页打开 **认证管理** 或 **AccessKey** 标签 2. 点击 **创建 AccessKey**,按提示完成安全校验 3. 创建成功后立即复制保存: - `AccessKey ID` → `ALIBABA_CLOUD_ACCESS_KEY_ID` - `AccessKey Secret` → `ALIBABA_CLOUD_ACCESS_KEY_SECRET` `AccessKey Secret` 只会在创建时显示一次。如果丢失,只能禁用旧密钥并重新创建。 如果你已经为手机号登录配置过同一个 RAM 用户的 AccessKey,直接复用即可,不要再为同一个用户创建多对 AccessKey。 ### 切换 Storage Provider 在 `packages/app-config/src/app-config.ts` 中,把 `common.storage.provider` 从 `"r2"` 改成 `"aliyun-oss"`: ```ts title="packages/app-config/src/app-config.ts" storage: { enabled: true, provider: "aliyun-oss", // 从 "r2" 改成 "aliyun-oss" publicPath: "/api/storage", // ...其余字段保持不变 }, ``` 切换后,所有 `avatar` / `attachment` 上传、下载、列举、删除都会自动走 OSS Provider,业务代码、上传组件、Better Auth 头像逻辑都不需要改。 ### 填入本地与生产环境变量 本地开发写入 `apps/server/.dev.vars`: ```bash title="apps/server/.dev.vars" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` 生产部署写入 `apps/server/.env.production`: ```bash title="apps/server/.env.production" ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret ALIYUN_OSS_BUCKET=your-oss-bucket ALIYUN_OSS_REGION=your-oss-region ALIYUN_OSS_ENDPOINT=your-oss-endpoint ``` 切换到 OSS 后,`R2_PUBLIC_URL` 不再需要,可以从两个环境文件中删除。`apps/server/wrangler.jsonc` 中的 `r2_buckets` 绑定(`STORAGE`)也不再被读取,可以保留以便随时切回,也可以删除。 ### 推送生产 Secrets 部署到 Cloudflare Workers 前,把生产密钥推送到 Workers Secrets: ```bash pnpm -F server secrets:bulk:production ``` 推送成功后,Worker 运行时通过 `env.ALIBABA_CLOUD_ACCESS_KEY_ID`、`env.ALIBABA_CLOUD_ACCESS_KEY_SECRET`、`env.ALIYUN_OSS_BUCKET`、`env.ALIYUN_OSS_REGION`、`env.ALIYUN_OSS_ENDPOINT` 读取这些变量。更新任意一个值后重新执行这条命令即可生效,不需要因为 Secret 变化重新部署代码。 ### 本地验证文件上传 启动服务端与客户端: ```bash pnpm dev:server pnpm dev:web ``` 登录后在个人资料页上传一张头像。前端会向 `/api/storage/upload` 提交文件,服务端调用 OSS Provider 的 `put` 方法把文件写入 `avatars//...`,并返回如下形式的公共 URL: ``` http://localhost:3001/api/storage/aliyun-oss/avatars//.png ``` 后续读取由服务端的 `/api/storage/aliyun-oss/` 路由代理回 OSS,Bucket 本身保持私有即可。 可以在 OSS 控制台的 **文件管理** 中确认对象已经写入对应前缀;在浏览器中直接打开上面那条公共 URL 也应该能看到图片。 # 存储服务 (http://page.easystarter.dev/docs/web/integrations/storage) ## 存储服务 EasyStarter 使用 [Cloudflare R2](https://developers.cloudflare.com/r2/) 作为对象存储服务,用于上传和管理用户文件。目前内置支持以下两种上传类型: | 上传类型 | 说明 | 文件大小限制 | | --- | --- | --- | | `avatar` | 用户头像 | 5 MB | | `attachment` | 附件文件(图片、PDF、文本) | 25 MB | ### 创建 R2 Bucket 官方文档:[R2 Getting started](https://developers.cloudflare.com/r2/get-started/) **方式一:通过 Cloudflare Dashboard** 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 2. 进入 **R2 Object Storage** 3. 点击 **Create bucket** 4. 输入 bucket 名称,例如 `your-app-bucket` 5. 选择地域(推荐 Automatic) 6. 点击 **Create bucket** **方式二:通过 Wrangler CLI** ```bash pnpm wrangler r2 bucket create your-app-bucket ``` ### 配置 wrangler.jsonc 在 `apps/server/wrangler.jsonc` 的 `r2_buckets` 字段中填入你的 bucket 名称: ```jsonc title="apps/server/wrangler.jsonc" "r2_buckets": [ { "binding": "STORAGE", "bucket_name": "your-app-bucket" } ], ``` - `binding` 固定为 `STORAGE`,这是 Worker 内部访问 R2 的变量名,不要修改 - `bucket_name` 改为你在 R2 创建的 bucket 名称 ### 开启公开访问并获取 R2_PUBLIC_URL 文件上传后,需要通过公开 URL 访问。推荐使用 R2 的 **Public Access** 功能。 **通过 Cloudflare Dashboard 开启(推荐)** 1. 进入你的 R2 bucket 详情页 2. 点击 **Settings** 标签 3. 在 **Public Access** 区域点击 **Allow Access** 4. 开启后会自动生成一个公开 URL,格式为:`https://pub-xxxxxxxx.r2.dev` 这个 URL 就是 `R2_PUBLIC_URL`。 **自定义域名(可选)** 也可以在 bucket Settings → Custom Domains 绑定自定义域名,例如 `https://cdn.yourdomain.com`,绑定后使用自定义域名作为 `R2_PUBLIC_URL`。 ### 填入 R2_PUBLIC_URL 环境变量 `R2_PUBLIC_URL` 需要填入两个环境变量文件: **本地开发**(`apps/server/.dev.vars`): ```bash title="apps/server/.dev.vars" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` **生产部署**(`apps/server/.env.production`): ```bash title="apps/server/.env.production" R2_PUBLIC_URL=https://pub-xxxxxxxx.r2.dev ``` `.env.production` 用于通过 `pnpm run secrets:bulk:production` 批量推送到 Cloudflare Workers 的 Secrets,不会直接参与构建。 ### 配置存储参数(可选) 存储相关参数在 `packages/app-config/src/app-config.ts` 的 `common.storage` 字段中定义,可按需调整: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "r2", publicPath: "/api/storage", // 文件访问的 API 路径前缀 keyPrefixes: { avatar: "avatars", // 头像文件存储路径前缀 attachment: "attachments", // 附件文件存储路径前缀 }, fallbackPrefix: "files", // 未指定类型时的兜底前缀 allowedTypes: { avatar: ["image/jpeg", "image/png", "image/gif", "image/webp"], attachment: ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain"], }, maxFileSizes: { avatar: 5 * 1024 * 1024, // 5 MB attachment: 25 * 1024 * 1024, // 25 MB }, }, ``` ## 扩展其他存储服务 EasyStarter 的存储层基于 `StorageProvider` 接口设计,只需四步即可接入任意存储服务(如 [AWS S3](https://aws.amazon.com/s3/)、[Cloudflare R2](https://developers.cloudflare.com/r2/)、[MinIO](https://min.io/) 等)。 ### 第一步:扩展服务商类型 假设以 S3 为例,在 `packages/app-config/src/types.ts` 中,将新服务商 key 追加到 `SUPPORTED_STORAGE_PROVIDERS`: ```ts title="packages/app-config/src/types.ts" export const SUPPORTED_STORAGE_PROVIDERS = ["r2", "s3"] as const; ``` ### 第二步:实现 Provider 在 `apps/server/src/storage/providers/` 下新建文件,实现 `StorageProvider` 接口: ```ts title="apps/server/src/storage/providers/s3.ts" import type { StorageProvider } from "../types"; export function createS3StorageProvider({ client, bucket }: { client: S3Client; bucket: string; }): StorageProvider { return { async put(key, data, options) { // 调用 S3 SDK 上传文件 }, async get(key) { // 调用 S3 SDK 下载文件 }, async head(key) { // 调用 S3 SDK 获取元数据 }, async delete(key) { // 调用 S3 SDK 删除文件 }, }; } ``` ### 第三步:注册到存储服务商中 在 `apps/server/src/storage/index.ts` 的 `providers` 对象中注册新 Provider: ```ts title="apps/server/src/storage/index.ts" import { createS3StorageProvider } from "./providers/s3"; const providers: Record = { r2: createR2StorageProvider({ bucket: storage }), s3: createS3StorageProvider({ client: s3Client, bucket: "your-bucket" }), }; ``` ### 第四步:切换配置 在 `packages/app-config/src/app-config.ts` 中将 `storage.provider` 改为新服务商的 key: ```ts title="packages/app-config/src/app-config.ts" storage: { provider: "s3", // 切换到新服务商 // ...其余配置保持不变 }, ``` 完成后,所有文件上传、下载、删除操作都会自动通过新 Provider 执行,无需修改业务代码。 # 项目结构 (http://page.easystarter.dev/docs/web/project-structure) import { File, Files, Folder } from "fumadocs-ui/components/files"; 概览 [#概览] * `apps/*` 放各端Apps * `packages/*` 放跨端复用代码 * `CLAUDE.md` Claude Code agent md文件 * `AGENTS.md` Codex agent md文件 顶层目录 [#顶层目录] * `apps/web` 是 Web SaaS 前端和文档站、博客内容 * `apps/server` 是 Hono + Cloudflare Workers 后端 * `apps/native` 是 Expo 移动端 * `packages/app-config` 统一业务配置,尤其是支付和存储策略 * `packages/api-client`提供共享 API 客户端 * `packages/i18n`提供多语言资源 * `packages/shared` 放跨端工具和基础类型 apps 目录 [#apps-目录] `apps` 下面是三个真正面向运行环境的应用。 * `web` 面向浏览器,覆盖营销页、博客、文档、认证和后台 * `server` 面向 API、认证、支付、邮件、数据库和存储 * `native` 面向移动端,复用同一套后端与共享配置 packages 目录 [#packages-目录] `packages` 用来承接跨应用共享逻辑,避免 Web、Server、Native 各写一套。 * `app-config` 是业务规则中心,尤其适合放支付套餐、能力开关、存储提供商选择 * `api-client` 让 Web 和 Native 可以以统一方式调用服务端 * `i18n` 统一管理多语言消息 * `shared` 放不依赖具体端的通用能力 Web 应用 [#web-应用] 如果你主要做 Web 端,核心关注 `apps/web` 这个应用即可。 * `content/` 是内容层,博客、作者、分类、文档都在这里 * `public/` 放图片、favicon、OG 图等静态资源 * `scripts/` 放内容校验之类的脚本 * `src/` 是 Web 应用主代码 * `e2e/` 放端到端测试 * `source.config.ts`、`vite.config.ts`、`wrangler.jsonc` 是 Web 构建与部署相关配置 Server 应用 [#server-应用] 后端能力集中在 `apps/server`。 * `src/` 是服务端主代码 * `drizzle.config.ts` 是数据库工具配置 * `wrangler.jsonc` 是 Cloudflare Workers 配置 Native 应用 [#native-应用] 移动端能力集中在 `apps/native`。 * `app/` 是移动端页面入口 * `components/` 和 `features/` 承载 UI 与业务模块 * `themes/` 负责主题样式 * `lib/` 负责连接基础能力 文档与内容 [#文档与内容] 文档、博客和作者资料统一放在 Web 应用的内容目录中。 * `content/docs/*` 是文档内容 * `content/blog/*` 是博客内容 * `content/author/*` 是作者资料 * `content/category/*` 是博客分类 阅读建议 [#阅读建议] * 想理解产品边界,先看顶层目录和 `apps/*` * 想理解共享能力,重点看 `packages/*` * 想改文档或博客,只需要进入 `apps/web/content` # 完整视频教程 (http://page.easystarter.dev/docs/web/video-tutorial)