Next.js & Node
VSA's rules are not tied to Laravel. A Next.js front end and a Node back end (NestJS) work with the same XAuth console as a VSA app: they sign in, check permissions, call APIs and mirror auth tables under the same rules, through TypeScript packages that mirror the PHP ones.
This page is for developers on a Next.js or Node project. The rest of this site covers the Laravel side. Everything set up on the console — the project, its applications, the credential, roles and permissions — is identical for both, and the console's own guides (Project setup, App authentication, Mirror setup) apply to you word for word. Only the app side differs, and that is what this page covers.
What stays the same on any stack
| Rule | What it means for you |
|---|---|
| The console is the truth | Identity, roles and permissions live on the engine. The app holds no auth table of its own |
| Permissions come from the engine's snapshot | Every permission check reads the snapshot the engine returns at login and introspect, never a local table, and never the mirror |
| 503 is not 401 | If the engine is unreachable, a cached session stays alive for a limited time instead of signing everyone out |
| One response shape | APIs answer {status_code, data, error_message} over HTTP 200. 401/402: sign in again, once, guarded against a sign-in loop. 403: a refusal, never a redirect to sign-in. 405/422: a business message |
| The browser never holds the bearer | Tokens live in HttpOnly cookies; a Next.js app calls its back end through its own proxy |
| Never authorise from the mirror | A mirror is for display and joins, exactly as on the XAuth mirror page |
The packages, side by side
| Job | PHP (VSA, Laravel) | Node, Next.js |
|---|---|---|
| Sign-in, sessions, permissions | vsd/xauth-client | @vsd/xauth-client-next: /core, /next, /react, /nest |
| Verify tokens locally (capability services) | vsd/xauth-client | @vsd/xauth-client-next/verify |
| Mirror auth tables | php artisan xauth:mirror:* | npx xauth-client mirror:*, @vsd/xauth-client-next/mirror (0.5.0+) |
| Calling the app's APIs | vsapi.js from the VSEL CDN | @vsd/vsapi-next, built on @vsd/vsapi-core — the shared core vsapi.js 2.0 is also being built from, so the two cannot drift apart |
Install
The packages are private, and only their built files are installed — the same rule Composer applies to
vsd/xauth-client. Add them as a Git dependency or from the GitHub Packages registry, as the
console's App authentication guide describes, with a read-only token that never goes into a
committed package.json. Then paste in the .env block the console gives you:
XAUTH_BASE_URL=https://devauth.vectorasoft.com
XAUTH_APP_ID=...
XAUTH_APP_SECRET=...
XAUTH_HTTP_TIMEOUT=20
XAUTH_SUBS_CODE=acme # on a host that serves ONE subscription
COOKIE_NAME=acme_at
XAUTH_SUBS_CODE matters: a login name is unique only within a subscription, so without it
sign-in works only until another subscriber picks the same name. Check the credential before writing any code:
npx xauth-client check
A Next.js front end
Three files connect the app: the client, the proxy, and the route handlers that own sign-in and refresh.
// lib/xauth.ts
import { createNextXauth } from '@vsd/xauth-client-next/next'
export const xauth = createNextXauth()
// proxy.ts (Next 16; middleware.ts on 15)
import { xauth } from '@/lib/xauth'
export const proxy = xauth.proxy({ publicPaths: ['/', '/about'] })
// app/api/xauth/[...xauth]/route.ts
import { xauth } from '@/lib/xauth'
export const { GET, POST } = xauth.handlers()
Permissions are checked the same way on the server and in the browser:
// a server component or server action
const user = await xauth.user()
if (!xauth.allowed(user, 312)) notFound()
// app/layout.tsx
<XauthProvider user={await xauth.browserUser()}>{children}</XauthProvider>
// a client component
const { user, can, logout } = useXauth()
<Can prn={312}><DeleteButton /></Can>
The proxy must leave /api/xauth/* alone. The engine replaces both tokens on
refresh and invalidates the old ones, so a proxy that handles POST /api/xauth/refresh spends the refresh
cookie before the handler can read it, and every refresh lands on the sign-in page. The
package's proxy already leaves that path alone; if you write your own proxy, exclude it too.
API calls go through @vsd/vsapi-next. You declare each endpoint once in a shape file, and every call
gets the VSA behaviour for free: response parsing, typed errors, timeouts, retries, one shared 401 refresh
and the sign-in loop guard.
import { endpoint } from '@vsd/vsapi-next/define'
export const shape = {
class: {
list: endpoint<Paginated<Class>, ListPayload>({ path: 'class/list-class' }),
update: endpoint<Class, ClassInput, string>({ method: 'PATCH', path: (id) => `class/update-class/${id}`, invalidates: ['class'] }),
},
}
A Node back end (NestJS)
One guard protects every route. A rotated access token goes back on X-Access-Token, exactly as the
Laravel APIAuthenticate middleware does it.
providers: [
{ provide: XAUTH_GATEWAY, useValue: createGateway() },
{ provide: APP_GUARD, useClass: XauthGuard },
]
@Get('me') me(@XauthUser() user: AuthSnapshot) {}
@Public() @Get('health') health() {}
@RequirePermission(312) @Post('save') save() {}
Admin screens list a subscription's people with engine.roster(), its branches with
branchLookup(), and its roles with roleOptions(). roster() returns a failure, not an empty page,
when the engine cannot answer, so a screen never shows "no users" when the real answer is "could not ask". The XAuth client API page lists every call with examples. A capability service
(chat, signal, storage) verifies tokens locally with /verify and SubscriberGuard; the
console's Capability services guide is the reference.
The mirror on Node
The same contract as the XAuth mirror: exact rows, exact values, exact
columns, a digest per run, a differential that escalates to a full refresh when the digest still differs,
reported drift, and the same xauth_mirror_state table. It uses the same allow-list, credentials and
flags as the PHP sync.
The mirror needs a database, so it runs where the database is — in a Next.js + NestJS product that is the
NestJS API, not the Next.js app. It writes to PostgreSQL through pg, an optional
peer dependency (pnpm add pg).
import { defineMirror } from '@vsd/xauth-client-next/mirror'
export default defineMirror({
// omitted = XAUTH_MIRROR_DATABASE_URL, then DATABASE_URL, then the PG* variables
connection: process.env.DATABASE_URL,
tables: {
um_users: { columns: ['id', 'login_name', 'full_name', 'user_class', 'status', 'subs_id', 'updated_at'] },
},
})
npx xauth-client mirror:tables # what the console lets this app mirror
npx xauth-client mirror:sync --dry-run # what it would create and pull; writes nothing
npx xauth-client mirror:sync # first run: full; afterwards: differential
npx xauth-client mirror:verify # exit 1 on drift - for cron and CI
The manifest lists columns only, because the types come from the console. The --table,
--full, --schema-only, --data-only, --replace-tables,
--since and --chunk flags mean what they mean in PHP, and the
xauth:mirror:sync spelling works too.
There is no Laravel scheduler, so schedule the sync yourself. As in PHP, the scheduled sync is what keeps the mirror correct:
*/15 * * * * cd /srv/acme-api && npx xauth-client mirror:sync >> /var/log/xauth-mirror.log 2>&1
0 3 * * * cd /srv/acme-api && npx xauth-client mirror:verify >> /var/log/xauth-mirror.log 2>&1
The same sync runs from code, in a scheduled job or a route that refreshes particular ids:
new MirrorSync(db, new MirrorClient(undefined, manifest), manifest).run(), or
.refreshIds('um_users', [12, 15]). A PostgreSQL advisory lock stops two syncs of the same table from overlapping.
| PHP has | On Node |
|---|---|
| Profiles, one per database | One config file is one database; --config=<path> for a second |
| Generated Eloquent models | None — read the mirror through your own query builder |
| The console's change callback, the queue hand-off | Not ported. The scheduled sync is the guarantee; the callback only made it faster |
status, doctor, watch | Not ported. npx xauth-client check proves the credential; mirror:verify proves the data |
Tables the console keys by something other than id (um_branches on a project that uses branch numbers) | Refused by name, rather than synced against the wrong key |
What has no twin yet
The pieces below are Laravel-only today. A Next.js project uses its own tooling for them:
- the
vsacommand line —vsa new, the generators,vsa check, the builds; - app files,
VSPageRouterand the VSEL components (a ListView port is planned); - the Report Center;
- the translation commands.
vsa xauth:register and vsa xauth:sync read Laravel app files, so a Node project's catalog
is set up on the console, or imported there from a project manifest with php artisan xauth:provision.
The other direction: a VSA front end on another back end
The VSA framework is defined by its browser side — VSApp, the router, the page contract — and that side
does not care which server it talks to. Laravel is one back end that provides it; Node, Python, Go or C# can
too, if they serve the same five things:
| What | Shape |
|---|---|
| the shell document | one HTML page per app, with the meta tags VSApp reads, the layout chrome, and every page root it carries hidden |
#vs-app-file | a JSON block: {app, home, main_route, alternative_route, pages, lazy} |
| one page's markup | GET /<main_route>/_page/<Name> for a declared lazy page only, under the same session |
| the page's files | script and stylesheet URLs: hashed when built, a cache-busting query otherwise |
| the APIs the pages call | {status_code, data, error_message} over HTTP 200 |
A Node back end that does this gets its auth and mirror from the packages above. The project template's
docs/framework.md has the full contract under "What the front end asks of a back end".