Upgrading

Upgrading

To the security release

This release closes the gaps a security review found in the pre-release versions. Most changes make a request that used to succeed — when it should not have — fail. Options that could never work (functions in runtimeConfig) now fail the build with a message pointing here, so nothing changes silently.

Authorization is deny-by-default

BeforeNow
An operation with no permission was allowedIt is refused: 401 anonymous, 403 signed in
A resource without authorization was openIt refuses every request; the build warns and names it
'*' had no special meaning'*' in ctx.permissions passes every operation (super-admin)
permissions.aggregate did not existIt gates /aggregate and ?aggregate=; falls back to read

Migrate: declare every operation you serve. true means public:

export const postsAuth: ResourceAuthConfig = {
  permissions: { read: true, create: ctx => !!ctx.user, update: 'posts:write', delete: 'admin' },
}

restore, purge and viewDeleted fall back to update, delete and restore — the hardcoded 'admin' role and org-role fallbacks for trash access are gone. See Authentication & Authorization.

Multi-tenancy is server-resolved and fails closed

  • Removed: multiTenancy.getTenantId, allowCrossTenantAccess, requireTenant — the build fails if set. They were functions read through serialized runtimeConfig, so they never reached the server, and resolution fell back to a client-supplied x-tenant-id header. That header is no longer read.
  • The tenant comes from ctx.tenant (context extender), event.context.tenantId or ctx.user[userTenantField] (default organizationId).
  • A scoped resource without a tenant answers 403 (401 anonymous) instead of returning unscoped rows.
  • New options: userTenantField, scopedResources, excludedResources.
  • The better-auth plugin maps session.activeOrganizationId onto ctx.user.organizationId.

Migrate: see Multi-Tenancy. Cross-tenant staff: set ctx.tenant.canAccessAllTenants from a context extender.

Rows are filtered everywhere

Tenant scoping, listFilter, soft delete and objectLevel now apply on every route — get, update, delete, restore, bulk, aggregate, ?include=, and both sides of M2M — not only the list. Rows the caller may not see answer 404 (not 403), so existence is not disclosed.

Server-owned columns are ignored in bodies

The primary key and createdAt on update, a primary key the database generates (auto-increment, serial, identity) on create, soft-delete columns, createdBy / updatedBy, the tenant column and a registration's new protectedFields are dropped from create/update bodies (and left out of generated validation schemas). A client can no longer move a row to another tenant, un-trash it, forge authorship, or push an auto-increment key to its maximum so that every later insert fails. A text / UUID key the database does not generate stays writable on create, so client-made ids keep working.

Field-level write rules are enforced

fields[x].write used to be reported by /permissions only — a request could still write the column. Now a create/update (or bulk item) that sets a field the caller may not write is refused with 403 (401 anonymous); the field is not silently dropped. To ignore a field instead of refusing, list it in the registration's protectedFields or remove it in a before* hook.

Queries are strict

  • An unknown or hidden field in filter, sort, fields, include or groupBy is a 400 (it used to be ignored, or leaked through hidden columns).
  • Filter operators: $eq $ne $gt $gte $lt $lte $like $in $nin $null, and a top-level $or; anything else is a 400 (see Filtering). $in takes at most 500 values.
  • ?include= checks the included resource's read permission (403), limits depth (3) and count (20), and allows filter/limit only on to-many relations.

Cursor pagination is keyset

Cursors are opaque keyset cursors that follow sort with the primary key as tiebreaker. Send cursor= (empty) on the first page; old cursors are rejected with 400. useAutoApiInfinite does this for you.

Removed options and APIs

RemovedUse instead
autoApi.hooksRegistration hooks via createModuleImport, or addResourceHook in a Nitro plugin (Lifecycle Hooks)
autoApi.exclude / autoApi.includeRegister only the resources you want to expose
defineAutoApiHandlercreateEndpoint — execute → handler, return the payload without { data } (Custom Endpoints)
GET /api/_m2m/debug-detection/api/_m2m/junctions, /is-junction/:table, /detect/:resource (signed-in only)
Server auto-import of internal helpersOnly the public API of @websideproject/nuxt-auto-api/utils is auto-imported
An inline validation object on a registration (was silently ignored)validation: createModuleImport(path, name) — inline now fails the build, like authorization and hooks
autoApi.database (was never read)The engine is the second argument of initializeDatabase(db, engine); the option is accepted but ignored
// Before
export default defineAutoApiHandler({
  async execute(context) {
    return { data: await loadStats(context) }
  },
})

// After
export default createEndpoint({
  resource: 'users',
  operation: 'get',
  async handler(ctx) {
    return loadStats(ctx)
  },
})

Custom endpoints

  • A named gate (endpointName → authorization.custom[name]) decides only the operations it declares; others fall back to the resource's permissions instead of being allowed.
  • A standalone createEndpoint (no resource) and getAutoApiContext resolve the caller but authorize nothing — gate them yourself. Use findAuthorizedRow / rowScope to read rows (Row access).

Hooks

  • beforeGet receives (id, ctx).
  • An error thrown by a hook keeps its status: throw createError({ statusCode: 409 }) answers 409 (it used to become a 500).
  • Your onSuccess / onError on composables run after the built-in ones instead of replacing them.

Bulk

RouteBody
POST /bulk{ items: [{…}] }
PATCH /bulk{ items: [{ id, data: {…} }] }
DELETE /bulk{ ids: [...] }

Each item goes through the single-record rules (validation, protected fields, row visibility, hooks). A transactional batch checks every item (and runs its before-hook) before writing any, writes them together — on D1 too, as one db.batch() — and runs the after-hooks once committed. The failure message is Bulk operation failed (nothing was written) for a failed check and … (rolled back) for a database error.

Soft delete

  • deletionId is always generated by the server.
  • Deleting an already-trashed row is a 404 unless ?force=true (purge).
  • Batch restore/purge are all-or-nothing across every row of the batch.
  • A cascade no longer re-stamps a child that was already in the trash: it keeps its own deletionId, and restoring the parent does not bring it back.
  • Soft delete with its cascade, batch restore/purge, bulk writes and M2M changes are all-or-nothing on every engine — on D1 as one db.batch(). atomicWrites() gives your own endpoints the same.
  • GET /api/permissions reports canRestore / canPurge / canViewDeleted as false on a table without soft delete.

Plugins

  • Listed in nuxt.config, built-in factories now work. They used to be stringified, losing their options (a rate limiter listed there threw at startup and limited nothing). They are now recreated from their options; an option that cannot be — an instance such as a store — fails the build. Register those from a file. plugins also takes both: ['~/server/autoapi-plugins', createExportPlugin()].
  • Plugins that add routes work, and are authorized. Export, file upload, the audit-log and activity feeds and token introspection used to crash the build when listed in nuxt.config (and had no routes from a file). Their routes now go through the same authorization as the generated ones — see the catalog. Export and upload routes are registered per resource (/api/{resource}/export); the feeds take limit / page / cursor instead of offset.
  • API tokens always belong to the caller who creates them (and their active organization): an owner in the body is ignored and an update cannot move a token.
  • Rate limiting keys on CF-Connecting-IP only on Cloudflare and on X-Forwarded-For only with trustProxy — a client can set either header elsewhere. Behind your own proxy, set trustProxy. The request metadata plugin reads its CF-* headers on the same rule.
  • Cache stores the whole response (pagination meta included) and is invalidated by every write route, including bulk, restore and M2M.

Composables

  • useAutoApiOptimisticUpdate(queryClient, resource, id, updates) — the query client is now the first argument.
  • useAutoApiUpdate takes { id, ...fields }.
  • Bulk composables send the bodies above; useAutoApiAggregate takes { aggregate, groupBy?, having?, filter? }.
  • All composables honour autoApi.prefix, and during SSR send the request's headers.

Admin

  • POST /api/admin/m2m/sync (unauthenticated) was removed; the admin UI uses the API's M2M routes.
  • autoAdmin.access and a custom page's canAccess were never applied (a function in nuxt.config cannot reach the running app); setting them now fails the build. Use autoAdmin.middleware: 'your-middleware' and customPages[].permissions: ['<resource>:<action>'], which are enforced.

Requirements

Nuxt 4 (>=4.0.0). The suite also runs against the Nuxt 5 nightly.

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.