---
title: "Upgrade to 0.2"
description: "Every change in 0.2 that needs something from you, with the edit to make: one section per change, newest first."
canonical_url: "https://nuxt-backend.dev/docs/ship/migrate/upgrade"
---
# Upgrade to 0.2

> Every change in 0.2 that needs something from you, with the edit to make: one section per change, newest first.

`0.1.0` on npm was a throwaway test publish, so most apps start on 0.2 directly. This page is for
apps built on a preview of `main` (`npm install https://pkg.pr.new/qruto/nuxt-backend@main`): it
lists every change that needs an edit in your app, newest first, with the edit to make. Run
`npx nuxt-backend doctor` after upgrading; it names any scaffolded function your `backend/` files
no longer export.

## Data composables take options and report failures in `error`

`useSearch`, `useAggregate`, `useWorkflowStatus`, `useEmailStatus`, `useSandboxInbox` and
`useSessions` follow [Composable conventions](https://nuxt-backend.dev/docs/reference/nuxt/conventions): inputs come in an
options object, each returns a plain object with `isLoading` and `error`, a read never throws, and
an action rejects. `error` is `{ code, data, message } | null`, the shape `getBackendError` reads;
a failure with no catalog code reads as `unavailable` with reason `unexpected`.

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        useSearch(query, term, { debounce, args })
      </code>
    </td>
    
    <td>
      <code>
        useSearch(query, { term, debounce, args })
      </code>
      
      , which also returns <code>
        error
      </code>
      
      . A whitespace-only term pauses it too
    </td>
  </tr>
  
  <tr>
    <td>
      The <code>
        UseSearchResult
      </code>
      
       type
    </td>
    
    <td>
      <code>
        UseSearchReturn
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useAggregate(query, args)
      </code>
      
       as a <code>
        ComputedRef<number>
      </code>
      
      , <code>
        0
      </code>
      
       while loading
    </td>
    
    <td>
      <code>
        { data, isLoading, error }
      </code>
      
      : <code>
        data
      </code>
      
       is <code>
        undefined
      </code>
      
       until the first read lands
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useCount
      </code>
    </td>
    
    <td>
      <code>
        useAggregate
      </code>
      
      , the same composable
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useWorkflowStatus(query, workflowId)
      </code>
      
       as a <code>
        ComputedRef
      </code>
      
       of the status
    </td>
    
    <td>
      <code>
        useWorkflowStatus(query, { workflowId })
      </code>
      
      , returning <code>
        { data, isLoading, error }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useEmailStatus(emailId, { api })
      </code>
    </td>
    
    <td>
      <code>
        useEmailStatus({ emailId, api })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useEmailStatus().isError
      </code>
    </td>
    
    <td>
      <code>
        isFailed
      </code>
      
      ; <code>
        error
      </code>
      
       now means the status could not be read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useSandboxInbox(address, { api })
      </code>
    </td>
    
    <td>
      <code>
        useSandboxInbox({ address, api })
      </code>
      
      , which also returns <code>
        error
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useSessions().error
      </code>
      
       as <code>
        Ref<string | null>
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<BackendError | null>
      </code>
      
      : show <code>
        error.message
      </code>
      
      , branch on <code>
        error.code
      </code>
      
      . It holds a failed read of the list too
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useSessions().revoke
      </code>
      
       and <code>
        revokeOthers
      </code>
      
       rejecting with a new <code>
        Error
      </code>
      
       carrying the message
    </td>
    
    <td>
      They reject with what the call threw; <code>
        isPending
      </code>
      
       is <code>
        true
      </code>
      
       while one runs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        describeUserAgent()
      </code>
      
       returning <code>
        'unknown device'
      </code>
      
       for no user agent, and <code>
        'Browser'
      </code>
      
       for one it could not read (<code>
        curl/8.4.0
      </code>
      
      )
    </td>
    
    <td>
      <code>
        null
      </code>
      
       for both: word an unknown device yourself
    </td>
  </tr>
  
  <tr>
    <td>
      A failed query in these composables throwing out of the render
    </td>
    
    <td>
      Nothing thrown: the failure is in <code>
        error
      </code>
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Move the second argument into the options object: `{ term }`, `{ workflowId }`, `{ emailId }`,
`{ address }`.
- Read `.data` from `useAggregate` and `useWorkflowStatus`
(`const { data: count } = useAggregate(api.aggregates.countMessages)`), and rename `useCount` to
`useAggregate`. Where a template bound the count straight to a number, write `count ?? 0`.
- Rename `isError` to `isFailed` on `useEmailStatus`.
- Where you showed `useSessions().error`, show `error.message`, and replace
`describeUserAgent(ua)` with `describeUserAgent(ua) ?? 'Unknown device'`.

## Each workspace pays through a customer of its own, and gifts are paid by the purchaser

Checkout found a workspace's billing customer by the email of the member who started it, so a
second workspace of the same owner landed on the first workspace's customer, with one set of
plans and credits for both. Every billing entity now has a provider customer of its own, keyed by
the entity's id: a workspace is a team customer with its owner as the owner member, and a user
(`billTo: 'user'`) an individual customer. Gifts moved onto the same model: the purchaser pays on
their own account and gets the receipt, and the recipient claims the pack at no cost into theirs.
[One customer per billing entity](https://nuxt-backend.dev/docs/billing/subscriptions#one-customer-per-billing-entity) and
[Gifts](https://nuxt-backend.dev/docs/billing/credits/gifts) have the details:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A checkout matching the billing customer by the member's email, and <code>
        forbidden
      </code>
      
       (reason <code>
        customer_taken
      </code>
      
      ) for a second workspace
    </td>
    
    <td>
      A customer per entity, keyed by its id; <code>
        customer_taken
      </code>
      
       only for a <code>
        billTo: 'user'
      </code>
      
       email the provider keeps for another account
    </td>
  </tr>
  
  <tr>
    <td>
      The portal as the customer's owner, for any member
    </td>
    
    <td>
      The caller's own member of the workspace's customer; another member is <code>
        forbidden
      </code>
      
       (reason <code>
        not_billing_manager
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      A second trial on another workspace of the same owner
    </td>
    
    <td>
      One trial per owner, recorded by their user id: the trialed workspace's deletion or transfer changes nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <BillingCheckoutLink>
      </code>
      
       and <code>
        <BillingPortalLink>
      </code>
      
       as the base module's links: a session on every render, a new tab, <code>
        trial-interval
      </code>
      
      , <code>
        trial-interval-count
      </code>
      
      , <code>
        embed
      </code>
      
      , <code>
        lazy
      </code>
      
      , <code>
        theme
      </code>
      
       and <code>
        polar-api
      </code>
      
       props
    </td>
    
    <td>
      The package's own, bound to <code>
        generateCheckoutLink
      </code>
      
       and <code>
        generateCustomerPortalUrl
      </code>
      
      : a session on click, in the same tab (<code>
        :redirect="false"
      </code>
      
       for a new one), <code>
        success-url
      </code>
      
       and <code>
        return-url
      </code>
      
       props, an <code>
        api
      </code>
      
       prop, and an <code>
        error
      </code>
      
       event
    </td>
  </tr>
  
  <tr>
    <td>
      A gift checkout billed to the recipient's customer, with their details pre-filled for the purchaser
    </td>
    
    <td>
      The pack's private gift product, bought on the purchaser's own customer; <code>
        billing sync
      </code>
      
       creates it and lists it under <code>
        gifts
      </code>
      
       in <code>
        billing.generated.ts
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Gifts attached automatically when the recipient had an account; <code>
        useGifts({ autoClaim })
      </code>
      
       and <code>
        <GiftClaimBanner auto-claim>
      </code>
    </td>
    
    <td>
      The recipient claims each gift: <code>
        useGifts().claim(giftId)
      </code>
      
      , which resolves <code>
        true
      </code>
      
       when the server confirmed it and <code>
        false
      </code>
      
       after opening the provider's $0 checkout. <code>
        claimGift
      </code>
      
       takes a required <code>
        giftId
      </code>
      
       and an optional <code>
        successUrl
      </code>
      
      , and resolves <code>
        { url }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A refunded gift that stayed claimable, and a partly refunded one delivered whole
    </td>
    
    <td>
      Any refund before the claim voids it (<code>
        forbidden
      </code>
      
      , reason <code>
        gift_refunded
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      A claim or the received-gift list for any session email
    </td>
    
    <td>
      A verified email only (<code>
        forbidden
      </code>
      
      , reason <code>
        no_email
      </code>
      
      ; <code>
        getReceivedGifts
      </code>
      
       answers <code>
        null
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      A gift from anyone, to any address
    </td>
    
    <td>
      Never to the purchaser's own address, only after an earlier paid order, at most 10 in 30 days (<code>
        setupBilling({ giftLimit })
      </code>
      
      ); <code>
        canGift
      </code>
      
       adds to those and receives <code>
        purchaserEntityId
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        forbidden
      </code>
      
       with reason <code>
        billing_profile_mismatch
      </code>
      
       from <code>
        claimGift
      </code>
    </td>
    
    <td>
      Nothing: the claim lands on the recipient's own customer
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        purchaserEmail
      </code>
      
      , <code>
        recipientEmail
      </code>
      
      , <code>
        purchaserUserId
      </code>
      
       and pending gifts in <code>
        getReceivedGifts
      </code>
    </td>
    
    <td>
      Paid and claimed gifts with <code>
        purchaserName
      </code>
      
      , <code>
        pack
      </code>
      
       and <code>
        message
      </code>
      
      ; nothing that names the purchaser's email or a billing id
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        purchaserEmail
      </code>
      
       and <code>
        message
      </code>
      
       in the <code>
        gift
      </code>
      
       email template's data
    </td>
    
    <td>
      <code>
        item
      </code>
      
      , the pack's name. The message shows only inside the app
    </td>
  </tr>
  
  <tr>
    <td>
      The <code>
        billing.gift_attach_failed
      </code>
      
       and <code>
        billing.gift_misdirected
      </code>
      
       log lines
    </td>
    
    <td>
      <code>
        billing.gift_unverified
      </code>
      
      , <code>
        billing.gift_claim_not_free
      </code>
      
      , <code>
        billing.gift_claim_hosted
      </code>
      
      , <code>
        billing.gift_refunded_after_claim
      </code>
      
      , <code>
        billing.gift_claimed_after_refund
      </code>
      
      , <code>
        billing.gift_over_limit
      </code>
      
      , <code>
        billing.customer_mapping_shared
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pending
      </code>
      
       in the DevTools gifts snapshot
    </td>
    
    <td>
      <code>
        claimed
      </code>
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Nothing to run for customers from 0.1: each gets its entity's id as its external id on its
first checkout or entitlement read, and a gift paid before 0.2 is received on its claim. Only
a customer two workspaces shared (the bug this fixes) is refused for the second one, logged as
`billing.customer_mapping_shared`: on a sandbox, start from a fresh organization, or delete
those customers in the provider's dashboard and clear the deployment's `customers` table of the
billing provider's component and the `backend` component's `billingEntitlements` cache.
- Issue a new organization access token with the `members:write` and `customer_sessions:write`
scopes besides the ones it has, and turn on customer members (the member model) for the
organization. `npx nuxt-backend doctor` reports both as `billing-team-customers`, and each pack's
gift product as `billing-gift-twins`.
- Run `npx nuxt-backend billing sync`, so each pack gets its private gift product.
- Replace `useGifts({ autoClaim })` with `useGifts()` and a claim button (or `<GiftClaimBanner>`
without `auto-claim`), and pass the gift's id to `claim`.
- Drop the base module's props from `<BillingCheckoutLink>` and `<BillingPortalLink>`; listen for
`error` to show a refusal.
- Read `item` instead of `message` and `purchaserEmail` in a `gift` template override.

## Checkout sells only your catalog, and returns only to your site

A checkout, a plan change and a gift used to take what the browser sent: any product the
provider sells, a trial of any length, a discount by id, metadata keys the package reads back,
and any URL to send the buyer to afterwards. The server decides each of them now, and refuses
the rest as `invalid_input` with the field it refuses. [What the server
decides](https://nuxt-backend.dev/docs/billing/subscriptions/checkout#what-the-server-decides) has the rules:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        generateCheckoutLink
      </code>
      
       with any product id
    </td>
    
    <td>
      Products of your catalog only (<code>
        billing.generated.ts
      </code>
      
      , or <code>
        setupBilling({ products })
      </code>
      
      ); <code>
        updateSubscription
      </code>
      
       and <code>
        changePlan
      </code>
      
       too
    </td>
  </tr>
  
  <tr>
    <td>
      A <code>
        successUrl
      </code>
      
      , <code>
        origin
      </code>
      
       or portal <code>
        returnUrl
      </code>
      
       on any site
    </td>
    
    <td>
      <code>
        SITE_URL
      </code>
      
      's origin, or one listed in the new <code>
        setupBilling({ trustedOrigins })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        trialInterval
      </code>
      
       and <code>
        trialIntervalCount
      </code>
      
       from the browser (<code>
        checkout(id, { trialInterval })
      </code>
      
      )
    </td>
    
    <td>
      The plan's own trial (the catalog's <code>
        trial
      </code>
      
      ); a different one, 1 to 30 days in days or weeks, or none (<code>
        trial: false
      </code>
      
      ), from server code through the new <code>
        billing.checkout(ctx, options)
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        discountId
      </code>
      
       from the browser
    </td>
    
    <td>
      <code>
        discountCode
      </code>
      
       for a code a customer or a campaign link carries; a discount by id through <code>
        billing.checkout(ctx, { discountId })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Metadata with any key
    </td>
    
    <td>
      Your own keys; <code>
        userId
      </code>
      
      , <code>
        gift
      </code>
      
      , <code>
        giftId
      </code>
      
      , <code>
        recipientEmail
      </code>
      
      , <code>
        purchaserUserId
      </code>
      
      , <code>
        giftRecipient
      </code>
      
       and <code>
        utm_*
      </code>
      
       are the package's, at most 20 keys
    </td>
  </tr>
  
  <tr>
    <td>
      Campaign UTM parameters as hand-made metadata
    </td>
    
    <td>
      <code>
        checkout(id, { utm: { source, medium, campaign, term, content } })
      </code>
      
      , stored as <code>
        utm_*
      </code>
      
       metadata
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        gift(productIds, …)
      </code>
      
       on <code>
        useBilling
      </code>
      
       and <code>
        useCredits
      </code>
      
      , for a plan or a pack
    </td>
    
    <td>
      <code>
        gift(pack, …)
      </code>
      
       with the pack's catalog key (<code>
        'credits500'
      </code>
      
      ): gifts are credit packs only, since a plan gift renews on the purchaser's card. <code>
        giftCheckout
      </code>
      
       takes <code>
        pack
      </code>
      
       in place of <code>
        productIds
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A plan change to any product, a credit pack included
    </td>
    
    <td>
      A recurring plan of the catalog only
    </td>
  </tr>
  
  <tr>
    <td>
      A webhook resolving the billing entity from an order's or a subscription's <code>
        userId
      </code>
      
       metadata
    </td>
    
    <td>
      Only the customer's own metadata (<code>
        customer.*
      </code>
      
       events, or the customer on the payload), then the synced cache: checkout metadata is the buyer's
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        gift(...)
      </code>
      
       with a one-time product the catalog does not list as a pack
    </td>
    
    <td>
      Run <code>
        billing sync
      </code>
      
      : it writes the catalog's pack keys into <code>
        billing.generated.ts
      </code>
      
       as <code>
        packs
      </code>
      
      , and only those gift
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        listAllProducts
      </code>
      
      , <code>
        changeCurrentSubscription
      </code>
      
       and <code>
        cancelCurrentSubscription
      </code>
      
       on <code>
        billing.api
      </code>
    </td>
    
    <td>
      Gone: <code>
        getConfiguredProducts
      </code>
      
       is the price list, and <code>
        updateSubscription
      </code>
      
       and <code>
        cancelSubscription
      </code>
      
       (<code>
        changePlan
      </code>
      
      , <code>
        cancel
      </code>
      
      ) change and cancel. <code>
        useBilling
      </code>
      
       no longer falls back to the removed pair
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Remove `listAllProducts`, `changeCurrentSubscription` and `cancelCurrentSubscription` from
the `billing.api` exports in `backend/billing.ts`.
- Run `npx nuxt-backend billing sync` again, so the catalog has your products and lists its packs,
and add any other origin the app runs on to `setupBilling({ trustedOrigins })`, with its scheme.
- Move a trial you set in the browser to the plan's `trial` in `billing.catalog.ts`, or to an
action of your own that calls `billing.checkout(ctx, { trial })`. Do the same with a
`discountId`, or pass the code as `discountCode`.
- Rename metadata keys that clash with the reserved ones, and pass UTM parameters as `utm`.
- Call `gift` with the pack's catalog key instead of its product id.

## Refusals carry a code, and survive production

Every refusal a person can meet now has a stable code. A package function throws it as a
`ConvexError` whose data is `{ code, ...fields }`, which Convex sends to the client in production.
Most refusals used to be plain `Error`s, which production shows as "Server Error". An auth route
puts the same code and fields in its error body. `getBackendError(error)`, auto-imported and also
exported from `nuxt-backend/functions`, reads either one, and `backendError()` throws one from
your own functions. The English sentences stay as each error's `message` for the log.
[Errors](https://nuxt-backend.dev/docs/reference/backend/errors) lists the codes:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A string in <code>
        error.data
      </code>
      
       from <code>
        requireUser
      </code>
      
      , <code>
        requireRole
      </code>
      
      , <code>
        requirePermission
      </code>
      
      , <code>
        requireOrganization
      </code>
      
      , <code>
        requireMember
      </code>
      
       and <code>
        setUserRole
      </code>
      
       (<code>
        'Unauthenticated'
      </code>
      
      , <code>
        'Forbidden: …'
      </code>
      
      , <code>
        'No active organization …'
      </code>
      
      )
    </td>
    
    <td>
      <code>
        unauthenticated
      </code>
      
      , <code>
        forbidden
      </code>
      
       with a <code>
        reason
      </code>
      
       (<code>
        banned
      </code>
      
      , <code>
        role
      </code>
      
      , <code>
        permission
      </code>
      
      , <code>
        not_member
      </code>
      
      , <code>
        workspace_role
      </code>
      
      ), <code>
        no_workspace
      </code>
      
      , and <code>
        invalid_input
      </code>
      
       for <code>
        setUserRole
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A string in <code>
        error.data
      </code>
      
       from checkout and the gift claim (one email, another workspace), <code>
        canGift
      </code>
      
       and <code>
        revokeSession
      </code>
    </td>
    
    <td>
      <code>
        forbidden
      </code>
      
       (reason <code>
        customer_taken
      </code>
      
       or <code>
        gift_refused
      </code>
      
      ) and <code>
        invalid_input
      </code>
      
       (field <code>
        sessionId
      </code>
      
      ), with the sentence in <code>
        data.message
      </code>
      
      ; <code>
        revokeSession
      </code>
      
       signed out is <code>
        unauthenticated
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Plain <code>
        Error
      </code>
      
      s from billing, credits, gifts, <code>
        setupAi
      </code>
      
       and <code>
        updateProfile
      </code>
      
      : signed out, no workspace, admin-only, not the caller's order or subscription, no subscription, missing <code>
        BILLING_ACCESS_TOKEN
      </code>
      
      , a balance that cannot cover a spend
    </td>
    
    <td>
      <code>
        unauthenticated
      </code>
      
      , <code>
        no_workspace
      </code>
      
      , <code>
        forbidden
      </code>
      
       (<code>
        admin_only
      </code>
      
      , <code>
        not_owner
      </code>
      
      , <code>
        not_recipient
      </code>
      
      , <code>
        no_email
      </code>
      
      ), <code>
        invalid_input
      </code>
      
      , <code>
        unavailable
      </code>
      
       (<code>
        not_configured
      </code>
      
      ), <code>
        not_ready
      </code>
      
       (<code>
        no_customer
      </code>
      
      , <code>
        not_synced
      </code>
      
      : nothing a retry changes), <code>
        insufficient_credits
      </code>
      
       with <code>
        balance
      </code>
      
       and <code>
        need
      </code>
      
      . A signed-out caller is <code>
        unauthenticated
      </code>
      
       before anything says billing is not configured, <code>
        spendCredits
      </code>
      
       and <code>
        refundCredits
      </code>
      
       included
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        getInvoiceUrl
      </code>
      
       for an order id the provider does not know: the provider's error
    </td>
    
    <td>
      <code>
        forbidden
      </code>
      
       (reason <code>
        not_owner
      </code>
      
      ), the same as another account's order
    </td>
  </tr>
  
  <tr>
    <td>
      "Too many …", "Rate limit reached" and "Credit budget reached" <code>
        Error
      </code>
      
      s from the <code>
        billingSync
      </code>
      
      , <code>
        ai
      </code>
      
       and <code>
        aiBudget
      </code>
      
       limits
    </td>
    
    <td>
      The limiter's own error (<code>
        throws: true
      </code>
      
      ), read as <code>
        rate_limited
      </code>
      
       with <code>
        limit
      </code>
      
       and <code>
        retryAfter
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      The provider's own error, body included, from <code>
        syncEntitlements
      </code>
      
      , <code>
        billing.getCustomerState
      </code>
      
      , <code>
        generateCustomerPortalUrl
      </code>
      
      , <code>
        changeCurrentSubscription
      </code>
      
      , <code>
        cancelCurrentSubscription
      </code>
      
      , <code>
        discounts.*
      </code>
      
      , a usage report and the email marketing calls
    </td>
    
    <td>
      <code>
        unavailable
      </code>
      
       with reason <code>
        provider_refused
      </code>
      
      . The provider's words go to the deployment log only
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        generateCustomerPortalUrl
      </code>
      
      , <code>
        changeCurrentSubscription
      </code>
      
       and <code>
        cancelCurrentSubscription
      </code>
      
       on <code>
        billing.api
      </code>
      
       from the provider's client
    </td>
    
    <td>
      The package's own, with the same arguments: the portal checks the token and spends one token of the <code>
        billingSync
      </code>
      
       limit, like every live provider call, and the two subscription actions run <code>
        updateSubscription
      </code>
      
       and <code>
        cancelSubscription
      </code>
      
       and resolve <code>
        null
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cancelCurrentSubscription
      </code>
      
       refused a <code>
        past_due
      </code>
      
       or <code>
        paused
      </code>
      
       subscription ("Subscription is not active")
    </td>
    
    <td>
      It cancels any running subscription, as <code>
        cancelSubscription
      </code>
      
       does
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        changeCurrentSubscription
      </code>
      
       and <code>
        cancelCurrentSubscription
      </code>
      
       threw the provider client's own error when the entity held several running subscriptions
    </td>
    
    <td>
      <code>
        invalid_input
      </code>
      
       (field <code>
        subscriptionId
      </code>
      
      ): call <code>
        updateSubscription
      </code>
      
       or <code>
        cancelSubscription
      </code>
      
       with the <code>
        subscriptionId
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        changeCurrentSubscription
      </code>
      
       to the current product refused with a plain <code>
        Error
      </code>
      
      ; <code>
        updateSubscription
      </code>
      
       sent it to the provider
    </td>
    
    <td>
      Both refuse with <code>
        invalid_input
      </code>
      
       (field <code>
        productId
      </code>
      
      ) before the provider is asked
    </td>
  </tr>
  
  <tr>
    <td>
      Auth route refusals with <code>
        { message, retryAfter? }
      </code>
      
       in the body
    </td>
    
    <td>
      The same, plus <code>
        code
      </code>
      
       and its fields: <code>
        rate_limited
      </code>
      
       with <code>
        limit
      </code>
      
      , <code>
        forbidden
      </code>
      
       with <code>
        reason
      </code>
      
       (<code>
        sign_in_refused
      </code>
      
      , <code>
        impersonating
      </code>
      
      , …), <code>
        unauthenticated
      </code>
      
      , <code>
        invalid_input
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A code or an invitation refused as undeliverable with <code>
        400
      </code>
    </td>
    
    <td>
      <code>
        503
      </code>
      
      , <code>
        unavailable
      </code>
      
       with service <code>
        email
      </code>
      
       and reason <code>
        test_mode
      </code>
      
       or <code>
        unconfigured
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useAiStream().start()
      </code>
      
       refused, with <code>
        status
      </code>
      
       left at <code>
        'pending'
      </code>
    </td>
    
    <td>
      <code>
        status
      </code>
      
       ends in <code>
        'error'
      </code>
      
      , and <code>
        error
      </code>
      
       holds the refusal
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Replace checks on `error.data` as a string, or on an error's `message`, with
`getBackendError(error)`: branch on `code`, and show `message`.
- A rate limiter you pass to `setupBilling` or `setupAi` that is not `setupRateLimiter`'s
is now called with `throws: true`. It may throw the limiter's `RateLimited` error or keep
answering `{ ok: false }`; both refuse as `rate_limited`.
- In your own functions, throw `backendError(code, data)` from `nuxt-backend/functions` where you
threw a `ConvexError` with a string, so one reader handles every refusal.

[Handle errors](https://nuxt-backend.dev/docs/data/errors) shows the reader and the thrower in use.

## The surface says backend, never a provider

The public names no longer say the platform or a provider. The base module's brand-named
auto-imports are hidden in an app that uses nuxt-backend, each with a neutral name in its place.
`setupBilling` takes an explicit option set with one entity resolver, and its result no longer
hands out the provider's client:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        useConvex()
      </code>
    </td>
    
    <td>
      <code>
        useBackendClient()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useConvexQuery
      </code>
      
      , <code>
        useConvexQueries
      </code>
      
      , <code>
        useConvexMutation
      </code>
      
      , <code>
        useConvexAction
      </code>
      
      , <code>
        useConvexPaginatedQuery
      </code>
      
      , <code>
        useConvexAsyncQuery
      </code>
      
      , <code>
        useConvexAsyncPaginatedQuery
      </code>
      
      , <code>
        useConvexUpload
      </code>
      
      , <code>
        useConvexUploadQueue
      </code>
      
      , <code>
        useConvexStorageUrl
      </code>
    </td>
    
    <td>
      The same name without <code>
        Convex
      </code>
      
      : <code>
        useQuery
      </code>
      
      , <code>
        useQueries
      </code>
      
      , …
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useConvexAuth()
      </code>
      
       · <code>
        useConvexConnectionState()
      </code>
    </td>
    
    <td>
      <code>
        useAuthState()
      </code>
      
       · <code>
        useConnectionState()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useBetterAuth()
      </code>
    </td>
    
    <td>
      <code>
        useAuth()
      </code>
      
      , which extends it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        provideConvexAuth
      </code>
      
      , <code>
        provideConvexApi
      </code>
      
      , <code>
        useConvexApi
      </code>
      
      , <code>
        useConvexNamespace
      </code>
      
       as auto-imports
    </td>
    
    <td>
      An explicit import from <code>
        nuxt-convex-module/client
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <ConvexImage>
      </code>
    </td>
    
    <td>
      <code>
        <StorageImage>
      </code>
      
      , the same component
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <CheckoutLink>
      </code>
      
       · <code>
        <CustomerPortalLink>
      </code>
    </td>
    
    <td>
      <code>
        <BillingCheckoutLink>
      </code>
      
       · <code>
        <BillingPortalLink>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <ConvexProviderWithClerk>
      </code>
      
       and <code>
        provideConvexAuthFromClerk
      </code>
      
       · <code>
        <ConvexProviderWithAuth0>
      </code>
      
       and <code>
        provideConvexAuthFromAuth0
      </code>
      
      , as auto-imports
    </td>
    
    <td>
      An explicit import from <code>
        nuxt-convex-module/clerk/vue
      </code>
      
       · <code>
        nuxt-convex-module/auth0/vue
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        definePageMeta({ middleware: 'convex-auth' })
      </code>
    </td>
    
    <td>
      <code>
        definePageMeta({ middleware: 'auth' })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        convexAuth(event)
      </code>
      
       in Nitro · <code>
        convexBetterAuthNuxt
      </code>
    </td>
    
    <td>
      <code>
        backendAuth(event)
      </code>
      
       · an explicit import from <code>
        nuxt-convex-module/better-auth/server
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        export const { provider } = billing
      </code>
      
       and <code>
        billing.provider
      </code>
    </td>
    
    <td>
      Nothing: the provider's client stays inside <code>
        setupBilling
      </code>
      
      . Its functions are on <code>
        billing.api
      </code>
      
       and <code>
        billing.functions
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupBilling({ getUserInfo, currentUserId })
      </code>
    </td>
    
    <td>
      <code>
        setupBilling({ resolveEntity })
      </code>
      
      : one <code>
        (ctx) => Promise<{ userId, email } | null>
      </code>
      
       for queries and actions alike, consulted whatever <code>
        billTo
      </code>
      
       says
    </td>
  </tr>
  
  <tr>
    <td>
      Provider options <code>
        setupBilling
      </code>
      
       passed through untyped
    </td>
    
    <td>
      Only the options <code>
        SetupBillingConfig
      </code>
      
       lists; anything else is a type error
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        BILLING_REFRESH_EVENTS
      </code>
      
       · <code>
        BILLING_WEBHOOK_PROVISION_EVENTS
      </code>
      
       from <code>
        nuxt-backend/billing
      </code>
    </td>
    
    <td>
      Nothing: <code>
        billing sync
      </code>
      
       registers the webhook events itself. <code>
        ALL_BILLING_EVENTS
      </code>
      
       stays
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sendArgs
      </code>
      
       from <code>
        nuxt-backend/email
      </code>
    </td>
    
    <td>
      Nothing: the scaffolded <code>
        send
      </code>
      
       is internal and takes its own validator
    </td>
  </tr>
  
  <tr>
    <td>
      The optional <code>
        clearPendingSpends
      </code>
      
      , <code>
        deleteByUser
      </code>
      
      , <code>
        cleanup
      </code>
      
      , <code>
        cleanupAbandoned
      </code>
      
       and <code>
        inbox
      </code>
      
       component refs
    </td>
    
    <td>
      Required, as the generated <code>
        components
      </code>
      
       object already has them; only a hand-written component object needs them added
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Delete `export const { provider } = billing` from `backend/billing.ts`, as the scaffold no
longer writes it.
- Rename the auto-imports, components and middleware in the table. `npx nuxi typecheck` names
every auto-import and middleware name left, since the hidden names leave the generated types.
It cannot see a component name in a template: in development, `<ConvexImage>`,
`<CheckoutLink>` and `<CustomerPortalLink>` still render their replacement and warn once in
the console, naming it, while a production build no longer knows them. Search your templates
for the old names before you ship.
- If `billing.ts` passed `getUserInfo` or `currentUserId`, replace both with one
`resolveEntity`. With `billTo: 'organization'`, the default, they were never consulted: delete
them.
- `/billing/events` and `/email/events` answer as before, with one difference: the 1 MiB cap
counts bytes, and a `Content-Length` over it is refused with `413` before the body is read. A
refused delivery also leaves a `webhook.rejected` line in the deployment log, once per isolate
for each route and outcome.

[Queries and mutations](https://nuxt-backend.dev/docs/data/queries-and-mutations), [Files](https://nuxt-backend.dev/docs/data/files) and
[Subscriptions](https://nuxt-backend.dev/docs/billing/subscriptions) show the neutral names in use.

## One-time codes are checked per address, and the unused routes are off

A code now gets at most a few guesses per address an hour, and a new code replaces the one before
it. Codes are stored as a hash keyed by `AUTH_SECRET`. Routes the package never calls are off,
and so is password reset until you turn passwords on. An email change says so in its first code,
and once it goes through, the old address hears of it:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        /email-otp/check-verification-otp
      </code>
    </td>
    
    <td>
      Nothing: it answers 404. The code routes check a code as they consume it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /send-verification-email
      </code>
      
      , <code>
        /verify-email
      </code>
      
      , <code>
        /change-email
      </code>
    </td>
    
    <td>
      Nothing: they answer 404. Verification and an email change go by code
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /email-otp/request-password-reset
      </code>
      
      , <code>
        /forget-password/email-otp
      </code>
      
      , <code>
        /email-otp/reset-password
      </code>
      
       and <code>
        type: 'forget-password'
      </code>
      
       codes, on a passwordless app
    </td>
    
    <td>
      Nothing: 404 and 400. They come back with <code>
        emailAndPassword.enabled
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth
      </code>
      
       with <code>
        email
      </code>
      
       undefined, or with <code>
        integrations
      </code>
    </td>
    
    <td>
      Nothing: both throw at setup, the first naming the import cycle that usually causes it
    </td>
  </tr>
</tbody>
</table>

**What to do**

- If you list or implement rate limits by name, add `emailOtpAttempt`; one built over
`DEFAULT_LIMITS` already has it.
- A `canSignIn` that switches on `purpose` gets `'email-change'` too, for the new address of
an email change.
- Codes minted before the upgrade stop verifying, and so do codes in flight when you rotate
`AUTH_SECRET`: ask for a new one.
- The old address of an email change now gets the `emailChanged` notice; restyle it like any
other template.

[Sign in](https://nuxt-backend.dev/docs/auth/sign-in), [Rate limits](https://nuxt-backend.dev/docs/ship/production/rate-limits) and
[Templates](https://nuxt-backend.dev/docs/email/templates) describe the rules.

## Workspace actions reject when they are refused

`useOrganization()`'s actions used to resolve the auth client's `{ data, error }` result, so a
refused call looked like a success unless you read `error`. Each now rejects with the server's
reason, and a rate-limited call rejects with the wait in `retryAfter`:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        const { error } = await invite({ email })
      </code>
      
      , and the same for <code>
        setActive
      </code>
      
      , <code>
        create
      </code>
      
      , <code>
        leave
      </code>
      
      , <code>
        acceptInvitation
      </code>
      
      , <code>
        declineInvitation
      </code>
      
       and <code>
        cancelInvitation
      </code>
    </td>
    
    <td>
      <code>
        try { await invite({ email }) } catch (error) { … }
      </code>
      
      ; on success each still resolves the client's result
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Where you read `error` from one of these results, catch the rejection instead. A call you never
checked now throws on refusal, so show its message where your form reports success.

[Workspaces](https://nuxt-backend.dev/docs/auth/workspaces) describes the actions.

## Every email comes from one registry, and every route is required

The app's `setupEmail` instance holds the templates of every email the package sends. `setupAuth`
and `setupBilling` take it as a required `email` option and send through it, so one override
restyles an email wherever it goes out. `registerBackendRoutes` takes every part, the agent token
exchange inside `auth`:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        emailTemplates
      </code>
      
       on <code>
        setupAuth
      </code>
      
      , and the set it returned as <code>
        emailTemplates
      </code>
    </td>
    
    <td>
      <code>
        templates
      </code>
      
       on <code>
        setupEmail
      </code>
      
      ; <code>
        await email.render(name, data)
      </code>
      
       for a preview
    </td>
  </tr>
  
  <tr>
    <td>
      The default as an override's second argument: <code>
        (data, byDefault)
      </code>
    </td>
    
    <td>
      <code>
        byDefault
      </code>
      
       in the second argument, beside <code>
        t
      </code>
      
      , <code>
        locale
      </code>
      
       and <code>
        brand
      </code>
      
      : <code>
        (data, { byDefault }) => …
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        email
      </code>
      
       on <code>
        setupAuth
      </code>
      
       as a transport, <code>
        (ctx, message) => …
      </code>
    </td>
    
    <td>
      <code>
        email
      </code>
      
      , the <code>
        setupEmail
      </code>
      
       instance, required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        giftEmail
      </code>
      
       on <code>
        setupBilling
      </code>
      
      , and <code>
        defaultGiftEmail
      </code>
      
      , <code>
        GiftEmailData
      </code>
      
       and <code>
        GiftEmailMessage
      </code>
      
       from <code>
        nuxt-backend/billing
      </code>
    </td>
    
    <td>
      The <code>
        gift
      </code>
      
       template on <code>
        setupEmail
      </code>
      
      ; <code>
        GiftEmailData
      </code>
      
       from <code>
        nuxt-backend/email
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupBilling(components)
      </code>
      
       with no options
    </td>
    
    <td>
      <code>
        setupBilling(components, { email })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuthEmailSender
      </code>
      
      , <code>
        AuthEmailMessage
      </code>
      
      , <code>
        AuthEmailTemplates
      </code>
      
      , <code>
        AuthEmailTemplateOverrides
      </code>
    </td>
    
    <td>
      <code>
        AuthEmail
      </code>
      
      ; <code>
        EmailMessage
      </code>
      
      , <code>
        EmailTemplates
      </code>
      
       and <code>
        EmailTemplateOverrides
      </code>
      
       from <code>
        nuxt-backend/email
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        registerBackendRoutes(http, { auth: { authComponent, createAuth }, mcp, … })
      </code>
      
      , every part but <code>
        auth
      </code>
      
       optional
    </td>
    
    <td>
      <code>
        registerBackendRoutes(http, { auth: { authComponent, createAuth, mcp }, billing, email, ai })
      </code>
      
      , every part required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupMcp
      </code>
      
      , <code>
        McpExchange
      </code>
      
       and <code>
        SetupMcpOptions
      </code>
      
       from <code>
        nuxt-backend/http
      </code>
    </td>
    
    <td>
      Nothing: <code>
        setupAuth
      </code>
      
       returns the exchange as <code>
        mcp
      </code>
      
      , typed <code>
        McpExchange
      </code>
      
       from <code>
        nuxt-backend/auth
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      The <code>
        billing.gift_email_unsent
      </code>
      
       log line
    </td>
    
    <td>
      Nothing: the gift email goes through the email instance
    </td>
  </tr>
</tbody>
</table>

The welcome email, the email-change notice and a gift notification carry an idempotency key, so
a repeated send of the same one delivers nothing while the first is on record. An invitation
carries none: every send of it goes out.

**What to do**

- In `backend/email.ts`, move the auth `emailTemplates` and the billing `giftEmail` overrides into
`setupEmail(components, { templates })`. An override that used its second argument as the
default takes `byDefault` from it instead.
- In `backend/auth.ts` and `backend/billing.ts`, import `email` from `./email` and pass it to
`setupAuth` and `setupBilling`. Keep `backend/email.ts` free of imports from `./auth`: the cycle
leaves `email` undefined while `setupAuth` runs.
- In `backend/http.ts`, move `mcp` into `auth: { authComponent, createAuth, mcp }` and pass
`billing`, `email` and `ai`.
- A preview page that read `emailTemplates` from `./auth` renders with `await email.render(name, data)`:
`render` resolves, so a template may be `async`.

[Templates](https://nuxt-backend.dev/docs/email/templates), [Auth setup](https://nuxt-backend.dev/docs/auth/setup) and
[Webhooks & routes](https://nuxt-backend.dev/docs/ship/production/webhooks) describe the new shapes.

## Email changes take a code from each address, and `setupAuth` is the one factory

An email change no longer sends links: the person enters a code from the current address, then one
from the new address. Verification is a code too, so the verification email and its section are
gone. Codes are stored as a hash keyed by `AUTH_SECRET`. `setupAuth` is the only function of `nuxt-backend/auth`, and its
cross-component options sit at the top level:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        setupAuth(components, query, { integrations: { rateLimiter, canSignIn, onUserCreated, onUserDeleted, email, emailTemplates } })
      </code>
    </td>
    
    <td>
      The same keys at the top level: <code>
        setupAuth(components, query, { rateLimiter, … })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        createAuthOptions
      </code>
      
       and <code>
        options
      </code>
      
       from <code>
        setupAuth
      </code>
      
       (and the scaffold's <code>
        backend/auth.ts
      </code>
      
      )
    </td>
    
    <td>
      Nothing: <code>
        createAuth
      </code>
      
       is the factory
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        createAuth
      </code>
      
      , <code>
        createAuthOptions
      </code>
      
      , <code>
        createBetterAuth
      </code>
      
      , <code>
        createBetterAuthOptions
      </code>
      
      , <code>
        makeAuthApi
      </code>
      
       exported from <code>
        nuxt-backend/auth
      </code>
    </td>
    
    <td>
      <code>
        setupAuth
      </code>
      
      , which returns <code>
        createAuth
      </code>
      
       and the auth queries
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defaultEmailTemplates
      </code>
    </td>
    
    <td>
      An override receives the default it replaces as <code>
        byDefault
      </code>
      
       (see the section above)
    </td>
  </tr>
  
  <tr>
    <td>
      The <code>
        verify
      </code>
      
       and <code>
        changeEmail
      </code>
      
       email templates
    </td>
    
    <td>
      Nothing: <code>
        otp
      </code>
      
       sends every code (<code>
        email-verification
      </code>
      
       to the current address, <code>
        change-email
      </code>
      
       to the new one)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        CreateBetterAuthOptions
      </code>
    </td>
    
    <td>
      <code>
        SetupAuthOptions
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useAuth().changeEmail
      </code>
      
      , <code>
        useAuth().sendVerificationEmail
      </code>
    </td>
    
    <td>
      <code>
        useEmailChange()
      </code>
      
      : <code>
        start
      </code>
      
      , <code>
        confirmCurrent
      </code>
      
      , <code>
        verifyNew
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <ProfileSettings callback-path>
      </code>
      
      , its <code>
        email-change-requested
      </code>
      
       event, and <code>
        newEmail
      </code>
      
       / <code>
        changingEmail
      </code>
      
       / <code>
        requestEmailChange
      </code>
      
       in its slots
    </td>
    
    <td>
      <code>
        email-changed(email)
      </code>
      
      , and <code>
        emailChange
      </code>
      
       (the <code>
        useEmailChange()
      </code>
      
       flow) in its slots
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <SecuritySettings callback-path>
      </code>
      
      , the <code>
        verification
      </code>
      
       section and slot, and its <code>
        verification-sent
      </code>
      
       event
    </td>
    
    <td>
      Nothing: the code that signs a person in verifies their address
    </td>
  </tr>
</tbody>
</table>

Better Auth's own rate limiter is now off unless you turn it on with
`authOptions.rateLimit: { enabled: true, storage: 'database' }`; an object without `enabled: true`
leaves it off. It
counted in memory, which on Convex lasts one function call. A new named limit, `emailChange`,
throttles the code an email change sends to the new address, per account.

**What to do**

- In `backend/auth.ts`, move the members of `integrations` up one level, and drop `createAuthOptions`
and `options` from the names you export.
- Replace calls to `changeEmail` and `sendVerificationEmail` with `useEmailChange()`, and drop
`callback-path` from `<ProfileSettings>` and `<SecuritySettings>`.
- A template override that called `defaultEmailTemplates.<key>(data)` takes the default as
`byDefault` instead (see the section above).
- If you passed `rateLimiter` from a `setupRateLimiter` of your own that lists limits by name, add
`emailChange`; one built over `DEFAULT_LIMITS` already has it.

[Auth setup](https://nuxt-backend.dev/docs/auth/setup), [Let users edit their profile](https://nuxt-backend.dev/docs/auth/accounts/profile) and
[Templates](https://nuxt-backend.dev/docs/email/templates) describe the new shapes.

## The welcome email and both sign-in methods are always on

Every new account gets the packaged welcome email, and the sign-in form always offers a passkey
and an email code. The switches are gone:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        setupAuth({ integrations: { welcomeEmail } })
      </code>
    </td>
    
    <td>
      Nothing: restyle the welcome with the <code>
        welcome
      </code>
      
       template on <code>
        setupEmail
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <AuthForm :passkeys>
      </code>
      
      , <code>
        <AuthForm :otp>
      </code>
    </td>
    
    <td>
      Nothing: build your own page on <code>
        useLoginFlow()
      </code>
      
       for a different set of steps
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useLoginFlow({ offerPasskey })
      </code>
    </td>
    
    <td>
      Nothing: after a code, the flow offers to save a passkey; <code>
        skipPasskey()
      </code>
      
       moves past it
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Delete those keys and props. A leftover `:passkeys` or `:otp` on `<AuthForm>` no longer does
anything but land on its root element as an attribute.
- A sign-in page of your own built on `useLoginFlow()` must handle the `add-passkey` step: render
it, or call `skipPasskey()` when `step` reaches it. A flow left there never calls `onSuccess`, and
the person, signed in already, stays on the page.
- If an onboarding sequence of yours sent its own welcome, drop that step: the packaged one
always goes out. The scaffold's `onSignup` sequence now starts three days after sign-up.

[Sign in](https://nuxt-backend.dev/docs/auth/sign-in) and [Email sequences](https://nuxt-backend.dev/docs/email/engagement/sequences) describe both.

## Pages, the stylesheet and the webhook log are always on

Every module page mounts, the neutral stylesheet always loads, and every webhook delivery is
logged. The switches are gone:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        backend.pages: false
      </code>
      
      , <code>
        backend.pages.<key>: false
      </code>
    </td>
    
    <td>
      Put an app page at the page's path: it replaces the module's
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        backend.pages.<key>: true
      </code>
    </td>
    
    <td>
      Leave the key out; a string still moves the page
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        backend.loginPath
      </code>
    </td>
    
    <td>
      <code>
        backend.pages.login
      </code>
      
      : the <code>
        auth
      </code>
      
       middleware follows it, whichever page serves it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        backend.css: false
      </code>
    </td>
    
    <td>
      Restyle through the <code>
        --bk-*
      </code>
      
       tokens or the <code>
        data-*
      </code>
      
       hooks: the stylesheet is layered, so your own CSS wins without a fight
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        nuxt-backend/ui.css
      </code>
      
      , <code>
        nuxt-backend/auth.css
      </code>
      
      , <code>
        nuxt-backend/*.css
      </code>
    </td>
    
    <td>
      Nothing to import: the module adds the stylesheet
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupBilling({ deliveryLog })
      </code>
      
      , <code>
        setupEmail({ deliveryLog })
      </code>
    </td>
    
    <td>
      Nothing: the log also dedupes redeliveries
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupBilling({ benefitMetadataTtlMs })
      </code>
    </td>
    
    <td>
      Nothing: a fixed 15 minutes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        getWebhookDeliveries
      </code>
      
       for any signed-in caller
    </td>
    
    <td>
      Admins only, by the gate <code>
        refundOrder
      </code>
      
       uses (<code>
        setupBilling({ requireAdmin })
      </code>
      
      ); anyone else reads <code>
        null
      </code>
      
      , and DevTools says so
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Delete those keys. Where you turned a page off, put your own page at its path; where you set
`loginPath`, set `pages.login` to the same path.
- If you imported a stylesheet from the package, drop the import. The stylesheet sits in the
`components.backend` cascade layer, ahead of your CSS.
- If your CSS has a reset or element rules outside a cascade layer (`* { margin: 0 }`,
`button { background: none }`), move them into `@layer base { … }`, or import the file into it:
`@import './reset.css' layer(base);`. A rule outside any layer beats every layered one, so an
unlayered reset strips the packaged buttons and inputs. Tailwind's own reset already sits in
`base`.
- Every module page now carries route rules: none is prerendered, and the private ones are kept
out of caches and indexes at their paths. A rule you set for the same path wins key by key. Keep
an app-wide `swr`, `isr` or `cache` rule off them; the module warns at startup when one covers
them ([Caches](https://nuxt-backend.dev/docs/ui/pages#caches)).
- A leftover 0.1 value (a page set to `true` or `false`, `backend.pages: false`, `loginPath` or
`css`) now stops the build with an error that names each one and links here.
- A page of yours that showed `getWebhookDeliveries` to everyone now gets `null` for non-admins.
To show the log more widely, serve it from a query of your own over
`components.backend.webhooks.listRecent`, as the playground does.

[Replace a page](https://nuxt-backend.dev/docs/ui/customize/replace-pages) and [Restyle with tokens](https://nuxt-backend.dev/docs/ui/customize/theming) describe both.

## Agents always pass the consent page

An agent used to receive an authorization code without anyone approving it. Every authorization
now stops at a consent page, `/oauth/consent`, where the person approves or denies, and agents
must use PKCE. The options that configured the provider are now paths only:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        setupAuth({ mcp: { loginPage } })
      </code>
    </td>
    
    <td>
      <code>
        mcp: { loginPath }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth({ mcp: { consentPage } })
      </code>
    </td>
    
    <td>
      <code>
        mcp: { consentPath }
      </code>
      
      , and the page at <code>
        backend.pages.agentConsent
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth({ mcp: { trustedClients } })
      </code>
    </td>
    
    <td>
      Nothing: every agent passes the consent page
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth({ mcp: { oidcConfig } })
      </code>
    </td>
    
    <td>
      Nothing
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Rename `loginPage` to `loginPath` and `consentPage` to `consentPath`, and delete
`trustedClients` and `oidcConfig`.
- If you had a consent page of your own, keep its path in `consentPath` and in
`backend.pages.agentConsent` (or put it at `/oauth/consent`), and build it on
`useAgentConsent()`. `npx nuxt-backend doctor` checks that both sides agree.
- Agents connected before the upgrade keep their tokens; the next authorization asks the person.

[OAuth & consent](https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent#consent) describes the page.

## Workspaces, admin and the agent surface are always on

Every app gets workspaces (with a personal workspace per user), roles and the OAuth-protected
agent endpoint at `/mcp`. The switches that turned them off are gone:

<table>
<thead>
  <tr>
    <th>
      Gone
    </th>
    
    <th>
      Instead
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        backend.workspaces
      </code>
      
       in <code>
        nuxt.config.ts
      </code>
      
      , and <code>
        runtimeConfig.public.backend.workspaces
      </code>
    </td>
    
    <td>
      Nothing: the organization endpoints always exist
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth({ admin: false })
      </code>
      
      , <code>
        { organization: false }
      </code>
      
      , <code>
        { mcp: false }
      </code>
    </td>
    
    <td>
      Pass an options object to configure a plugin, or leave the key out
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        organization: { personal: false }
      </code>
    </td>
    
    <td>
      Every user gets a personal workspace on first sign-in
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setupAuth({ verbose })
      </code>
    </td>
    
    <td>
      Nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        backend.mcp: false
      </code>
      
      , <code>
        backend.mcp.enabled
      </code>
    </td>
    
    <td>
      Nothing: <code>
        backend.mcp
      </code>
      
       takes options only
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        backend.mcp.tools.builtin
      </code>
      
      , <code>
        builtinToolEnabled
      </code>
      
       from <code>
        nuxt-backend/mcp
      </code>
    </td>
    
    <td>
      An agent sees a built-in tool only with its scope, which the person consents to
    </td>
  </tr>
</tbody>
</table>

**What to do**

- Delete those keys from `nuxt.config.ts` and `backend/auth.ts`.
- If your app ran without workspaces, run `npx nuxt-backend doctor`: `listWorkspaces` and
`listWorkspaceMembers` are part of the function contract again, so `backend/auth.ts` must
re-export them. A user who signs in after the upgrade gets a personal workspace.

[Workspaces](https://nuxt-backend.dev/docs/auth/workspaces) and [MCP server](https://nuxt-backend.dev/docs/agents/mcp) describe both.

## Local installation is gone

`installation: 'local'` copied the `backend` component into `backend/components/backend/` so an
app could change its schema. Every app now mounts the packaged component, which owns its schema. The
option, `init --installation`, the `schema` option of `setupAuth` and the six
`nuxt-backend/component/*` subpaths are gone.

**What to do**

- Back up the deployment first: `npx convex export --path backup.zip`.
- In `backend/convex.config.ts`, import the packaged component instead of the local one:```ts [backend/convex.config.ts]
import backend from 'nuxt-backend/convex.config'
```
- In `backend/auth.ts`, remove `schema: authSchema` from `setupAuth` and its import.
- Delete `backend/components/backend/`, and `installation` from the `backend` options in
`nuxt.config.ts`.
- Remove `@convex-dev/better-auth` and `@convex-dev/resend` from your `package.json` unless your
own code imports them; the package brings both.
- Run `npx nuxt-backend doctor`.

The packaged component mounts under the same name, `backend`, so the data in its tables stays where
it is. What you added to the component schema needs a new home first:

- **An extra table** becomes an app table in `backend/schema.ts`. Empty it in the component
(`npx convex import --component backend --table <name> --replace -y empty.jsonl`, with an empty
file), switch, then load its rows from the backup into the app table
(`npx convex import --table <name> <file>`).
- **An extra field** on an auth table has no place in the packaged schema. Move the value to an app
table keyed by the user id, then clear the field before you switch, or start a dev deployment
fresh.

[Auth setup](https://nuxt-backend.dev/docs/auth/setup) lists what the packaged schema covers.

## The scaffolded `send` is internal

`api.email.send` in the scaffolded `backend/email.ts` was a public action that only checked for a
sign-in, so anyone who could sign in could send mail from your verified domain to any address. It
is an `internalAction` now, and the welcome step of the onboarding sequence sends plain text.

**What to do**

- Call `send` from your own server code: `ctx.runAction(internal.email.send, { to, subject, text })`
from an action, a scheduled function, or `npx convex run email:send`. Take the address from data
you trust: the signed-in user's own record, a workspace invitation, an order.
- If a person types the address (an admin tool), use the scaffold's `sendAsAdmin`, built with
`admin.action`, or build your own the same way.
- If you kept your own `backend/email.ts`, make the same change: replace `action` with
`internalAction` for any function that takes a recipient.

[Sending email](https://nuxt-backend.dev/docs/email/sending) shows the new scaffold.

## Sessions are listed and revoked by id

The device-session list on `/security` failed for anyone who signed in more than a day ago, because
Better Auth's own `/list-sessions` refuses an older session. The list now comes from two functions
`setupAuth` returns, which work for sessions of any age and never send a session token to the
browser.

**What to do**

- Re-export the two functions from `backend/auth.ts`:```ts [backend/auth.ts]
export const {
  // …the exports you already have
  listSessions,
  revokeSession,
} = setupAuth(components, query, { /* … */ })
```
- In your own code, `useSessions().revoke(id)` takes a session id instead of a token, and each
session is `{ id, userAgent, createdAt, updatedAt, expiresAt, current }` with dates as epoch
milliseconds. The list is live, so `refresh()` and the `immediate` option are gone.
- A `<SecuritySettings>` listener on `session-revoked` receives the session id.

[Auth setup](https://nuxt-backend.dev/docs/auth/setup) lists every function `setupAuth` returns.


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
