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
| Before | Now |
|---|---|
| An operation with no permission was allowed | It is refused: 401 anonymous, 403 signed in |
A resource without authorization was open | It 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 exist | It 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 serializedruntimeConfig, so they never reached the server, and resolution fell back to a client-suppliedx-tenant-idheader. That header is no longer read. - The tenant comes from
ctx.tenant(context extender),event.context.tenantIdorctx.user[userTenantField](defaultorganizationId). - 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.activeOrganizationIdontoctx.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,includeorgroupByis 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).$intakes at most 500 values. ?include=checks the included resource'sreadpermission (403), limits depth (3) and count (20), and allowsfilter/limitonly 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
| Removed | Use instead |
|---|---|
autoApi.hooks | Registration hooks via createModuleImport, or addResourceHook in a Nitro plugin (Lifecycle Hooks) |
autoApi.exclude / autoApi.include | Register only the resources you want to expose |
defineAutoApiHandler | createEndpoint — 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 helpers | Only 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(noresource) andgetAutoApiContextresolve the caller but authorize nothing — gate them yourself. UsefindAuthorizedRow/rowScopeto read rows (Row access).
Hooks
beforeGetreceives(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/onErroron composables run after the built-in ones instead of replacing them.
Bulk
| Route | Body |
|---|---|
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
deletionIdis 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/permissionsreportscanRestore/canPurge/canViewDeletedasfalseon 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.pluginsalso 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 takelimit/page/cursorinstead ofoffset. - 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-IPonly on Cloudflare and onX-Forwarded-Foronly withtrustProxy— a client can set either header elsewhere. Behind your own proxy, settrustProxy. The request metadata plugin reads itsCF-*headers on the same rule. - Cache stores the whole response (pagination
metaincluded) 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.useAutoApiUpdatetakes{ id, ...fields }.- Bulk composables send the bodies above;
useAutoApiAggregatetakes{ 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.accessand a custom page'scanAccesswere never applied (a function innuxt.configcannot reach the running app); setting them now fails the build. UseautoAdmin.middleware: 'your-middleware'andcustomPages[].permissions: ['<resource>:<action>'], which are enforced.
Requirements
Nuxt 4 (>=4.0.0). The suite also runs against the Nuxt 5 nightly.
Pagination
@websideproject/nuxt-auto-api supports offset (page) pagination and cursor (keyset) pagination. Every list is capped: ?limit defaults to pagination.defaultLimit (20) and can never exceed pagination.maxLimit (100).
Soft Deletes
Soft deletes mark records as deleted (a timestamp) instead of removing them, with restore, purge, cascade-to-children, and batch (cascade-group) restore/purge.