# 08B Backend auth / billing / credits — aieditorrsp.net

- Date: 2026-05-29
- Task: t_a439ff77
- Assignee: 墨枢
- Status: IMPLEMENTED_DEPLOYED
- Domain: https://aieditorrsp.net
- Repo: /root/projects/aieditorrsp
- Commit: 55b3b4b0a73569ca54931bee6d63f4f0d9aaecf8
- Deploy version: 6b327c84-add3-4bda-afb5-10a7703c2c74

## Scope delivered

Implemented real backend routes and ledger for:

- Google OAuth login with CSRF state cookie and secure HttpOnly session cookie.
- D1 binding `DB` to `aieditorrsp-db` (`a9d3f65c-15e6-406f-8147-e20e3f526fb6`).
- D1 schema for `users`, `credit_accounts`, `usage_records`, `orders`, `webhook_events`.
- Stripe Checkout route supporting `monthly`, `yearly`, `credit_pack`.
- Stripe Checkout hard tax flags:
  - `automatic_tax[enabled]=true`
  - `billing_address_collection=required`
  - `tax_id_collection[enabled]=true`
- Stripe webhook route with raw body signature verification.
- Credit accounting for `/api/generate-image`: checks ledger before provider, deducts only after successful provider result.
- Unsafe prompt block remains before provider and before credit reservation.

## Changed files

- `wrangler.jsonc`
- `migrations/0001_auth_billing_credits.sql`
- `src/lib/server.ts`
- `src/app/api/auth/login/route.ts`
- `src/app/api/auth/callback/google/route.ts`
- `src/app/api/auth/me/route.ts`
- `src/app/api/auth/logout/route.ts`
- `src/app/api/checkout/stripe/route.ts`
- `src/app/api/webhooks/stripe/route.ts`
- `src/app/api/credits/route.ts`
- `src/app/api/generate-image/route.ts`

## API schema

### GET `/api/auth/login?return_to=/path`

Redirects to Google OAuth.

- Auth: anonymous
- CSRF: stores opaque state in `aieditorrsp_oauth_state` HttpOnly Secure SameSite=Lax cookie.
- Response: `302 Location: https://accounts.google.com/o/oauth2/v2/auth?...`
- Errors:
  - `503 GOOGLE_OAUTH_NOT_CONFIGURED`

### GET `/api/auth/callback/google?code=...&state=...`

Exchanges Google OAuth code, upserts user, initializes credit account, signs session.

- Auth: Google OAuth callback
- Cookie: sets `aieditorrsp_session` HttpOnly Secure SameSite=Lax, 7 days
- Response: `302 Location: APP_ORIGIN + return_to`
- Errors:
  - `400 OAUTH_STATE_INVALID`
  - `503 AUTH_NOT_CONFIGURED`
  - `502 GOOGLE_TOKEN_EXCHANGE_FAILED`
  - `502 GOOGLE_PROFILE_INVALID`

### GET `/api/auth/me`

Returns current session state.

```json
{ "authenticated": true, "user": { "id": "usr_...", "email": "...", "name": "...", "avatar_url": "...", "plan": "free|pro" } }
```

Anonymous:

```json
{ "authenticated": false, "user": null }
```

### POST/GET `/api/auth/logout`

Clears session cookie.

```json
{ "ok": true }
```

### GET `/api/credits`

Returns free + paid credit state and checkout links.

```json
{
  "authenticated": false,
  "user": null,
  "plan": "free",
  "daily_limit": 2,
  "free_remaining": 2,
  "paid_remaining": 0,
  "remaining": 2,
  "paid_enabled": true,
  "checkout": {
    "monthly": "/api/checkout/stripe?plan=monthly",
    "yearly": "/api/checkout/stripe?plan=yearly",
    "credit_pack": "/api/checkout/stripe?plan=credit_pack"
  }
}
```

### GET/POST `/api/checkout/stripe`

Creates a Stripe Checkout Session.

- Auth: required. Anonymous users get `302` to Google login with return path back to checkout.
- Query/body:

```json
{ "plan": "monthly" }
```

Allowed plan values:

- `monthly` → Pro monthly, 200 monthly credits
- `yearly` → Pro yearly, 200 monthly credits
- `credit_pack` → one-time 100 purchased credits

POST response:

```json
{ "ok": true, "url": "https://checkout.stripe.com/...", "id": "cs_..." }
```

GET response: `302 Location: https://checkout.stripe.com/...`

Errors:

- `400 INVALID_PLAN`
- `503 CHECKOUT_NOT_CONFIGURED`
- `502 STRIPE_CHECKOUT_FAILED`

### POST `/api/webhooks/stripe`

Receives Stripe webhooks.

- Auth: Stripe signature only
- Signature: verifies raw request body with `STRIPE_WEBHOOK_SECRET`
- Supported events:
  - `checkout.session.completed`
  - `invoice.payment_succeeded`
  - `customer.subscription.deleted`
- Idempotency: `webhook_events.stripe_event_id` unique.
- Response:

```json
{ "ok": true }
```

Errors:

- `503 WEBHOOK_NOT_CONFIGURED`
- `400 STRIPE_SIGNATURE_INVALID`

### POST `/api/generate-image`

Generates edited image with fal provider.

- Input: multipart form-data
  - `prompt`: string, min length 12
  - `image`: JPG/PNG/WebP, <= 8 MB
- Safety: unsafe prompt regex blocks before image provider and before credit reservation.
- Credit rule:
  - Requires available anonymous daily, logged-in free daily, monthly, or purchased credit.
  - Deducts exactly 1 credit only after provider returns a usable image URL.
  - Provider failure or timeout does not deduct.

Success:

```json
{
  "ok": true,
  "provider": "fal",
  "model": "fal-ai/flux-pro/kontext",
  "request_id": "...",
  "image_url": "https://...",
  "preview_url": "https://...",
  "download_url": "https://...",
  "credits_charged": 1,
  "credit_source": "monthly|purchased|free_daily|anonymous_free"
}
```

Errors include:

- `400 PROMPT_REQUIRED`
- `400 UNSAFE_PROMPT_BLOCKED`
- `400 IMAGE_REQUIRED`
- `400 UNSUPPORTED_IMAGE_TYPE`
- `400 IMAGE_TOO_LARGE`
- `402 CREDITS_REQUIRED`
- `402 LOGIN_REQUIRED`
- `503 CREDITS_NOT_CONFIGURED`
- `503 PROVIDER_NOT_CONFIGURED`
- `503 PROVIDER_FAILURE`
- `504 PROVIDER_TIMEOUT`

## Migration evidence

Migration file:

- `/root/projects/aieditorrsp/migrations/0001_auth_billing_credits.sql`

Commands run:

```bash
npx wrangler d1 execute aieditorrsp-db --file=migrations/0001_auth_billing_credits.sql
npx wrangler d1 execute aieditorrsp-db --remote --file=migrations/0001_auth_billing_credits.sql
```

Remote migration result:

- DB: `aieditorrsp-db`
- ID: `a9d3f65c-15e6-406f-8147-e20e3f526fb6`
- Processed: 9 queries
- Final bookmark: `00000001-00000006-0000507a-ed09b2c2873a7a840fdf5c32c811bf05`
- Verified remote tables:
  - `users`
  - `credit_accounts`
  - `usage_records`
  - `orders`
  - `webhook_events`

Worker secrets verified present by name only:

- `FAL_KEY`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `JWT_SECRET`
- `STRIPE_PRICE_ID_CREDIT_PACK`
- `STRIPE_PRICE_ID_MONTHLY`
- `STRIPE_PRICE_ID_YEARLY`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`

Secrets were not printed.

## Verification

Commands run:

```bash
npm run verify
npm run build
npm run deploy
```

Results:

- `npm run verify`: passed, `ok=true`, routes=11, href placeholders=0, forbidden copy=0.
- `npm run build`: passed; OpenNext dynamic routes include auth, checkout, credits, generate-image, webhook.
- `npm run deploy`: passed.
- Deployment binding evidence shows `env.DB (aieditorrsp-db)` attached.
- Current Version ID: `6b327c84-add3-4bda-afb5-10a7703c2c74`.
- Git status after push/deploy: clean and `main...origin/main`.

Production smoke tests with browser-like UA:

- `GET https://aieditorrsp.net/api/credits` → 200, `paid_enabled:true`.
- `GET https://aieditorrsp.net/api/auth/me` → 200, anonymous session shape OK.
- `GET https://aieditorrsp.net/api/auth/login?return_to=/pricing` → 302 to Google OAuth with correct callback `https://aieditorrsp.net/api/auth/callback/google`.
- `GET https://aieditorrsp.net/api/checkout/stripe?plan=monthly` anonymous → 302 to `/api/auth/login?return_to=/api/checkout/stripe?plan=monthly`.
- Unsafe `/api/generate-image` prompt → 400 `UNSAFE_PROMPT_BLOCKED`, confirming hard safety block before provider.

Note: direct Python default UA received Cloudflare `1010` Bot Fight 403. Browser-like UA smoke passed.

## Known limits / next handoff

- Full Google OAuth callback and Stripe Checkout paid completion need browser/account E2E by QA because they require human Google login and/or Stripe test/live flow.
- Frontend still needs wiring for login/logout, credit status display, and checkout CTA state if not already connected.
- Stripe webhook should be tested with Stripe CLI or Dashboard replay after QA has a checkout session; code verifies raw body signature and idempotency.
