@smarthivelabs-devs/identity-sdk
v1.4.7
Published
SmartHive IdentityCore client SDK — JWT verification, Express/Next.js middleware, and catalog registration
Readme
@smarthivelabs-devs/identity-sdk
SmartHive IdentityCore client SDK. Plug any service into the SmartHive identity platform in minutes — JWT verification, permission checks, catalog registration, Express/Next.js middleware, workspace handoff, and a CLI seed tool.
- No database access — all identity state lives in IdentityCore
- No Better Auth dependency — uses the internal token API
- Works everywhere — Express backends, Next.js App Router, NestJS services, Edge Runtime
- CJS + ESM — ships both formats; works with
require()andimportout of the box
Table of Contents
- Install
- Environment Variables
- Core Concepts
- Express Backend Integration
- Next.js Dashboard Integration
- Workspace Handoff
- System Registration
- Authorization Helpers
- Client Reference
- Error Handling
- Exports Map
- Requirements
Install
npm install @smarthivelabs-devs/identity-sdkPeer dependencies are optional — only install what your service uses:
# For Express backends
npm install express
# For Next.js dashboards
npm install nextEnvironment Variables
Set these in .env / .env.local / your hosting dashboard (Render, Vercel, etc.).
Backend services (Express)
| Variable | Required | Description |
|---|---|---|
| IDENTITY_CORE_URL | Yes | Base URL of the SmartHive IdentityCore service. E.g. https://identity-core.smarthivelabs.dev |
| IDENTITY_SERVICE_SECRET | Yes | The INTERNAL_SERVICE_SECRET shared with IdentityCore |
| IDENTITY_APP_KEY | Yes | Your app's registered key in IdentityCore. E.g. mailer, votyhive |
| IDENTITY_ENV | No | DEV, STAGING, or PROD (case-insensitive — prod works too). Used for environment-scoped app access checks |
| WORKSPACE_BACKEND_URL | No | Base URL of the SmartHive Workspace Backend. Required for registerSystem(). E.g. https://workspace-backend.smarthivelabs.dev |
| WORKSPACE_INTERNAL_SECRET | No | Shared secret for service-to-service calls to the Workspace Backend. Required for registerSystem() |
Next.js dashboards / frontends
| Variable | Required | Description |
|---|---|---|
| IDENTITY_CORE_URL | Yes | Same as above — used in server-side Route Handlers |
| IDENTITY_SERVICE_SECRET | Yes | Same as above |
| IDENTITY_APP_KEY | Yes | Your app's registered key |
| NEXT_PUBLIC_APP_URL | Yes | The public URL of this app. E.g. https://admin-votyhive.smarthivelabs.dev |
| NEXT_PUBLIC_IDENTITY_URL | No | Public IdentityCore URL for browser-visible links |
| NEXT_PUBLIC_WORKSPACE_URL | No | SmartHive Workspace URL. E.g. https://workspace.smarthivelabs.dev |
| WORKSPACE_HANDOFF_SECRET | No | Required if receiving workspace handoffs. Must match IDENTITY_INTERNAL_SECRET from the Workspace backend |
| WORKSPACE_BACKEND_URL | No | Required for registerSystem(). Base URL of the Workspace Backend |
| WORKSPACE_INTERNAL_SECRET | No | Required for registerSystem(). Shared secret for internal Workspace calls |
Core Concepts
How auth works end-to-end
Browser / Client
│
│ Bearer <JWT> (or cookie sh_access_token)
▼
Your Service ──── POST /v1/token/access ──► IdentityCore
│ x-internal-service-secret │
│ │ returns { active, staffId, access }
│ ◄──────────────────────────────────────────┘
│
req.shic = { staffId, userId, access: { roles, permissions, apps } }The JWT is issued by IdentityCore after a staff member logs in via SmartHive Workspace or direct login. Your service never issues tokens — it only verifies them.
Token cache
IdentityClient caches verified token contexts in-process (default 30 seconds, capped at token expiry). This means verifyToken() hits IdentityCore at most once per 30 seconds per unique token — not on every request. The cache is bounded at 2,000 entries with expire-first eviction.
Express Backend Integration
1. Create the identity client
Create a single shared client instance — one per service, not per request.
// src/identity.ts
import { IdentityClient } from '@smarthivelabs-devs/identity-sdk';
export const identity = new IdentityClient({
identityCoreUrl: process.env.IDENTITY_CORE_URL!,
serviceSecret: process.env.IDENTITY_SERVICE_SECRET!,
appKey: process.env.IDENTITY_APP_KEY ?? 'my-service',
env: process.env.IDENTITY_ENV ?? null, // 'dev', 'prod', 'DEV', 'PROD' all accepted
loginUrl: process.env.NEXT_PUBLIC_WORKSPACE_URL
? `${process.env.NEXT_PUBLIC_WORKSPACE_URL}/login`
: undefined,
// Required only if calling registerSystem()
workspaceUrl: process.env.WORKSPACE_BACKEND_URL,
workspaceInternalSecret: process.env.WORKSPACE_INTERNAL_SECRET,
cacheTtlMs: 30_000, // default — safe to omit
throwOnRegistrationFailure: true, // default — throws if catalog registration fails on boot
});2. Define your catalog
Declare your app's permissions, roles, apps, and departments. This is idempotent — all upserts, safe to call on every boot.
// src/catalog.ts
import { defineCatalog } from '@smarthivelabs-devs/identity-sdk';
export const catalog = defineCatalog({
name: 'my-service',
// Register your app (and optionally its environments/modules)
apps: [
{
key: 'my-service',
name: 'My Service',
environments: [
{ env: 'DEV', baseUrl: 'http://localhost:4000' },
{ env: 'PROD', baseUrl: 'https://my-service.smarthivelabs.dev' },
],
modules: [
{ key: 'my-service.reports', name: 'Reports' },
{ key: 'my-service.admin', name: 'Admin' },
],
},
],
// Declare every permission key your service uses
permissions: [
{ key: 'my-service.items.read', description: 'View items' },
{ key: 'my-service.items.write', description: 'Create and edit items' },
{ key: 'my-service.items.delete', description: 'Delete items' },
{ key: 'my-service.reports.view', description: 'View reports' },
{ key: 'my-service.admin.manage', description: 'Full admin access' },
],
// Declare roles and which permissions they bundle
roles: [
{
key: 'my_service_viewer',
name: 'My Service — Viewer',
permissions: ['my-service.items.read', 'my-service.reports.view'],
},
{
key: 'my_service_editor',
name: 'My Service — Editor',
permissions: ['my-service.items.read', 'my-service.items.write', 'my-service.reports.view'],
},
{
key: 'my_service_admin',
name: 'My Service — Admin',
isSystem: true,
permissions: [
'my-service.items.read',
'my-service.items.write',
'my-service.items.delete',
'my-service.reports.view',
'my-service.admin.manage',
],
},
],
});3. Register on startup
// src/server.ts
import express from 'express';
import { identity } from './identity.js';
import { catalog } from './catalog.js';
import { itemsRouter } from './routes/items.js';
async function start() {
// Register permissions, roles, and apps with IdentityCore.
// If IdentityCore is unreachable this throws and prevents startup.
await identity.registerCatalog(catalog);
const app = express();
app.use(express.json());
app.use('/api', itemsRouter);
app.listen(4000);
}
start();4. Protect routes with middleware
// src/routes/items.ts
import { Router } from 'express';
import { requireAuth, requirePermission, requirePermissions, requireRole, requireApp } from '@smarthivelabs-devs/identity-sdk/express';
import { Scope } from '@smarthivelabs-devs/identity-sdk';
import { identity } from '../identity.js';
const router = Router();
// ── Basic auth ──────────────────────────────────────────────────────────────
// requireAuth verifies the Bearer token and attaches the identity context
// to req.shic. Every protected route must call requireAuth first.
router.get('/items', requireAuth(identity), async (req, res) => {
const { staffId, access } = req.shic!;
res.json({ items: [], staffId });
});
// ── Single permission ───────────────────────────────────────────────────────
router.get('/items',
requireAuth(identity),
requirePermission('my-service.items.read'),
async (_req, res) => { res.json({ items: [] }); }
);
router.post('/items',
requireAuth(identity),
requirePermission('my-service.items.write'),
async (req, res) => { res.json({ created: true }); }
);
router.delete('/items/:id',
requireAuth(identity),
requirePermission('my-service.items.delete'),
async (req, res) => { res.json({ deleted: req.params.id }); }
);
// ── Permission with scope ───────────────────────────────────────────────────
// Use Scope builders instead of writing { scopeType, scopeRef } by hand.
// GLOBAL scope (default — no Scope arg needed, shown here for clarity)
router.get('/reports',
requireAuth(identity),
requirePermission('my-service.reports.view', Scope.global()),
async (_req, res) => { res.json({ report: {} }); }
);
// DEPARTMENT scope — staff must have the permission scoped to that department
router.get('/departments/:deptId/items',
requireAuth(identity),
requirePermission('my-service.items.read', (req) => Scope.department(req.params.deptId)),
async (req, res) => { res.json({ deptId: req.params.deptId, items: [] }); }
);
// APP scope — staff must have the permission scoped to the app
router.get('/apps/:appKey/settings',
requireAuth(identity),
requirePermission('my-service.admin.manage', (req) => Scope.app(req.params.appKey)),
async (req, res) => { res.json({ settings: {} }); }
);
// ── Multiple permissions ────────────────────────────────────────────────────
// 'all' — staff must hold EVERY listed permission (AND logic)
router.put('/items/:id',
requireAuth(identity),
requirePermissions(['my-service.items.read', 'my-service.items.write'], 'all'),
async (req, res) => { res.json({ updated: req.params.id }); }
);
// 'any' — staff must hold AT LEAST ONE listed permission (OR logic)
router.get('/admin/overview',
requireAuth(identity),
requirePermissions(['my-service.admin.manage', 'my-service.reports.view'], 'any'),
async (_req, res) => { res.json({ overview: {} }); }
);
// ── Role-based guard ────────────────────────────────────────────────────────
router.post('/admin/settings',
requireAuth(identity),
requireRole('my_service_admin'),
async (_req, res) => { res.json({ saved: true }); }
);
// ── App access guard ────────────────────────────────────────────────────────
// Checks that the staff member has been granted access to the app itself.
router.get('/dashboard',
requireAuth(identity),
requireApp('my-service', 'PROD'),
async (_req, res) => { res.json({ dashboard: {} }); }
);
// ── Cookie-based auth (optional) ────────────────────────────────────────────
// Useful when your service sets an httpOnly cookie instead of using Bearer headers.
// Requires cookie-parser middleware installed and mounted before this route.
router.get('/me',
requireAuth(identity, { cookieName: 'my_service_session' }),
async (req, res) => {
res.json({ staffId: req.shic!.staffId });
}
);
export { router as itemsRouter };5. Access identity context in handlers
After requireAuth, req.shic is fully typed and available in all downstream middleware and handlers.
router.get('/profile', requireAuth(identity), async (req, res) => {
const ctx = req.shic!;
// Identity fields
ctx.staffId; // string — the authenticated staff member's ID
ctx.userId; // string — the linked SmartHive user ID
ctx.authzVersion; // number — increment tracked by IdentityCore
ctx.departmentIds; // string[] — all departments the staff member belongs to
ctx.claims; // Record<string, unknown> — raw JWT claims
// Access context
ctx.access.roles; // string[] — role keys held
ctx.access.permissions; // Array<{ key, scopeType, scopeRef }> — all grants
ctx.access.apps; // Array<{ appKey, env }> — app access list
});6. Manual permission checks inside handlers
For conditional logic that doesn't map cleanly to a single middleware:
import { hasPermission, hasRole, hasAnyPermission, hasAppAccess, Scope } from '@smarthivelabs-devs/identity-sdk';
router.get('/items/:id', requireAuth(identity), async (req, res) => {
const { access } = req.shic!;
// Check a global permission
const canRead = hasPermission(access, 'my-service.items.read');
// Check a role
const isAdmin = hasRole(access, 'my_service_admin');
// Check any of several permissions
const canViewOrManage = hasAnyPermission(access, [
'my-service.items.read',
'my-service.admin.manage',
]);
// Check a scoped permission
const canManageDept = hasPermission(
access,
'my-service.items.write',
Scope.department('dept-abc123')
);
// Check app access
const hasAccess = hasAppAccess(access, 'my-service', 'PROD');
if (!canRead) return res.status(403).json({ error: 'forbidden' });
res.json({ item: {}, isAdmin, canManageDept });
});7. CLI: register catalog without server startup
# Install the SDK globally or use npx:
IDENTITY_CORE_URL=https://identity-core.smarthivelabs.dev \
IDENTITY_SERVICE_SECRET=your-secret \
npx smarthive-id seed --catalog ./src/catalog.jsAuto-discovery order when --catalog is omitted:
smarthive.catalog.ts/smarthive.catalog.js(project root)src/identity/catalog.ts/src/identity/catalog.js
Output:
✓ Catalog registered: my-service
permissions: 5 upserted
apps: 1 upserted
roles: 3 upserted
permissionLinks: 9 upsertedNext.js Dashboard Integration
A Next.js admin dashboard has three auth layers, each with a different job:
| Layer | Where | What it does |
|---|---|---|
| withIdentity in middleware.ts | Edge Runtime | Fast cookie + expiry check. Redirects to login. No IdentityCore call. |
| withAuth HOC | Route Handlers (Node.js) | Full JWT verification via IdentityCore. Enforces permissions. |
| Token refresh | Server Action / Route Handler | Re-issues token using the refresh session. |
1. Create the identity client
// src/lib/identity.ts
import { IdentityClient } from '@smarthivelabs-devs/identity-sdk';
export const identity = new IdentityClient({
identityCoreUrl: process.env.IDENTITY_CORE_URL!,
serviceSecret: process.env.IDENTITY_SERVICE_SECRET!,
appKey: process.env.IDENTITY_APP_KEY ?? 'my-dashboard',
env: process.env.IDENTITY_ENV ?? null, // 'dev', 'prod', 'DEV', 'PROD' all accepted
loginUrl: `${process.env.NEXT_PUBLIC_WORKSPACE_URL ?? ''}/login`,
// Required only if calling registerSystem()
workspaceUrl: process.env.WORKSPACE_BACKEND_URL,
workspaceInternalSecret: process.env.WORKSPACE_INTERNAL_SECRET,
});2. Edge middleware — protect all pages
// middleware.ts (project root)
import { NextResponse } from 'next/server';
import { withIdentity } from '@smarthivelabs-devs/identity-sdk/nextjs';
export default withIdentity((_req) => NextResponse.next(), {
// Pages that don't require authentication
publicPaths: ['/login', '/api/auth/handoff'],
// Where to redirect unauthenticated browsers
loginPath: `${process.env.NEXT_PUBLIC_WORKSPACE_URL ?? '/login'}/login`,
// Cookie name holding the access token (must match what your backend sets)
tokenCookieName: 'sh_access_token',
// Skip /api/* routes — Route Handlers manage their own auth
skipApiRoutes: true,
// Path of your handoff exchange route (default: '/api/auth/handoff').
// Only needed if your handoff route is at a non-default path.
// handoffPath: '/api/auth/handoff',
});
// Only run middleware on page routes, not static assets
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};withIdentity does not call IdentityCore. It:
- Intercepts any request that has a
?workspace_handoff=<token>query param and redirects it to the handoff route (/api/auth/handoffby default), so the token is consumed before any session check runs - Checks if the token cookie exists
- Decodes the
expclaim (no signature verification) - Redirects to login if missing or expired
- Sets
?returnTo=<current url>so the login page can redirect back
3. Protected Route Handlers
// src/app/api/items/route.ts
import { NextResponse } from 'next/server';
import { withAuth } from '@smarthivelabs-devs/identity-sdk/nextjs';
import { identity } from '@/lib/identity';
// GET /api/items — requires the read permission
export const GET = withAuth(
identity,
async (_req, { identity: ctx }) => {
// ctx is a fully typed IdentityContext
return NextResponse.json({ staffId: ctx.staffId, items: [] });
},
{ requiredPermission: 'my-service.items.read' }
);
// POST /api/items — requires the write permission
export const POST = withAuth(
identity,
async (req, { identity: ctx }) => {
const body = await req.json();
return NextResponse.json({ created: true, by: ctx.staffId });
},
{ requiredPermission: 'my-service.items.write' }
);4. Role-based Route Handler
// src/app/api/admin/settings/route.ts
import { NextResponse } from 'next/server';
import { withAuth } from '@smarthivelabs-devs/identity-sdk/nextjs';
import { identity } from '@/lib/identity';
// Only staff with the admin role can access this
export const POST = withAuth(
identity,
async (req, { identity: ctx }) => {
const settings = await req.json();
return NextResponse.json({ saved: true, by: ctx.staffId });
},
{ requiredRole: 'my_service_admin' }
);5. Multi-permission Route Handler
// src/app/api/reports/route.ts
import { NextResponse } from 'next/server';
import { withAuth } from '@smarthivelabs-devs/identity-sdk/nextjs';
import { identity } from '@/lib/identity';
// Pass if the staff has AT LEAST ONE of these permissions (OR logic)
export const GET = withAuth(
identity,
async (_req, { identity: ctx }) => NextResponse.json({ report: {}, viewer: ctx.staffId }),
{
requiredPermissions: {
keys: ['my-service.reports.view', 'my-service.admin.manage'],
mode: 'any',
},
}
);
// Pass only if the staff has ALL of these permissions (AND logic)
export const POST = withAuth(
identity,
async (req, { identity: ctx }) => NextResponse.json({ submitted: true }),
{
requiredPermissions: {
keys: ['my-service.reports.view', 'my-service.items.write'],
mode: 'all',
},
}
);6. App-guarded Route Handler
// src/app/api/platform/route.ts
import { NextResponse } from 'next/server';
import { withAuth } from '@smarthivelabs-devs/identity-sdk/nextjs';
import { identity } from '@/lib/identity';
// Only staff with explicit access to 'my-service' in PROD may call this
export const GET = withAuth(
identity,
async (_req, { identity: ctx }) => NextResponse.json({ ok: true }),
{ requiredApp: 'my-service', requiredAppEnv: 'PROD' }
);7. Token from cookie (default)
withAuth reads the token from the Authorization: Bearer header first, then falls back to the sh_access_token cookie. Override the cookie name:
export const GET = withAuth(
identity,
handler,
{
requiredPermission: 'my-service.items.read',
tokenCookieName: 'my_custom_cookie',
}
);8. Access identity context in Server Components
Verify the token server-side inside a Server Component or Server Action:
// src/app/(platform)/dashboard/page.tsx
import { cookies } from 'next/headers';
import { identity } from '@/lib/identity';
export default async function DashboardPage() {
const cookieStore = await cookies();
const token = cookieStore.get('sh_access_token')?.value;
if (!token) return redirect('/login');
const ctx = await identity.verifyToken(token);
if (!ctx) return redirect('/login?error=session_expired');
return (
<main>
<h1>Welcome, {ctx.claims.display_name as string}</h1>
<p>Roles: {ctx.access.roles.join(', ')}</p>
</main>
);
}9. Manual permission checks in Server Components
import { hasPermission, hasRole, hasAnyPermission, Scope } from '@smarthivelabs-devs/identity-sdk';
import { cookies } from 'next/headers';
import { identity } from '@/lib/identity';
export default async function ItemsPage() {
const cookieStore = await cookies();
const token = cookieStore.get('sh_access_token')?.value ?? '';
const ctx = await identity.verifyToken(token);
if (!ctx) return redirect('/login');
const canWrite = hasPermission(ctx.access, 'my-service.items.write');
const isAdmin = hasRole(ctx.access, 'my_service_admin');
const canAct = hasAnyPermission(ctx.access, ['my-service.items.write', 'my-service.admin.manage']);
return (
<div>
{canWrite && <button>New Item</button>}
{isAdmin && <a href="/admin">Admin Panel</a>}
</div>
);
}10. Login page — workspace SSO + fallback
The recommended login flow for SmartHive apps: staff click "Continue with SmartHive Workspace" which triggers a signed handoff from the Workspace launcher. The form also shows errors from failed handoffs.
// src/app/(auth)/login/LoginForm.tsx
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
const WORKSPACE_URL = process.env.NEXT_PUBLIC_WORKSPACE_URL ?? 'https://workspace.smarthivelabs.dev';
const HANDOFF_ERRORS: Record<string, string> = {
configuration_error: 'Workspace sign-in is not configured. Contact your administrator.',
invalid_signature: 'Workspace sign-in token is invalid. Please try again.',
expired: 'Workspace sign-in token has expired. Please try again.',
malformed: 'Workspace sign-in token is malformed. Please try again.',
missing: 'No workspace sign-in token was received.',
backend_unavailable: 'Backend is unreachable. Please try again later.',
backend_rejected: 'Workspace sign-in was rejected. Contact your administrator.',
};
export function LoginForm() {
const searchParams = useSearchParams();
const handoffReason = searchParams.get('reason') ?? '';
const [error, setError] = useState<string | null>(
searchParams.get('error') === 'handoff_failed'
? (HANDOFF_ERRORS[handoffReason] ?? 'Sign-in failed. Please try again.')
: null
);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
// ... your normal login logic
}
return (
<form onSubmit={onSubmit}>
{error && <p className="text-red-500">{error}</p>}
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign In</button>
<div>— or —</div>
{/* Workspace SSO button */}
<a href={`${WORKSPACE_URL}/systems`}>
Continue with SmartHive Workspace
</a>
</form>
);
}11. Catalog registration on Next.js boot
Register your catalog in the server startup script or an instrumentation hook:
// src/instrumentation.ts (Next.js 14+ instrumentation hook)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { identity } = await import('./lib/identity');
const { catalog } = await import('./lib/catalog');
// Register permissions, roles, and apps with IdentityCore
await identity.registerCatalog(catalog);
// Register this app in the Workspace app-registry so it appears
// in the SmartHive Workspace systems launcher
await identity.registerSystem({
app_key: 'my-dashboard',
name: 'My Dashboard',
description: 'Optional — shown in the launcher',
launch_url: process.env.NEXT_PUBLIC_APP_URL ?? 'https://my-dashboard.smarthivelabs.dev',
launch_mode: 'signed_handoff',
required_permission: 'my-service.items.read', // omit to allow all staff
sort_order: 10,
});
}
}Note:
instrumentation.tsonly runs whenNEXT_RUNTIME === 'nodejs'. It does not fire in Cloudflare Workers / edge deployments. For Cloudflare Pages, register the system from a separate Node.js service (e.g. a NestJSonModuleInit) or run it as a one-off deploy step.
Workspace Handoff (SmartHive Launcher)
When a staff member clicks your app in the SmartHive Workspace systems launcher, the Workspace backend redirects them to your app with a signed workspace_handoff query parameter. Your app verifies the signature and creates a local session.
How it works
Staff clicks app in Workspace launcher
│
▼
Workspace Backend
- looks up staff access for your appKey
- signs a HandoffPayload with WORKSPACE_HANDOFF_SECRET
- redirects to: https://my-dashboard.smarthivelabs.dev/api/auth/handoff
?workspace_handoff=<signed_payload>
&returnTo=/dashboard
│
▼
Your app's /api/auth/handoff route
- verifies the signature with verifySignedHandoff()
- calls your backend to exchange the payload for a session
- sets session cookies
- redirects to returnToNext.js handoff route
// src/app/api/auth/handoff/route.ts
import { type NextRequest, NextResponse } from 'next/server';
import { verifySignedHandoff } from '@smarthivelabs-devs/identity-sdk/handoff';
const HANDOFF_SECRET = process.env.WORKSPACE_HANDOFF_SECRET ?? '';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
function loginError(reason: string) {
return NextResponse.redirect(
new URL(`/login?error=handoff_failed&reason=${encodeURIComponent(reason)}`, APP_URL),
{ status: 303 }
);
}
export async function GET(request: NextRequest) {
if (!HANDOFF_SECRET) {
console.error('[handoff] WORKSPACE_HANDOFF_SECRET is not set');
return loginError('configuration_error');
}
const handoffParam = request.nextUrl.searchParams.get('workspace_handoff');
const returnTo = request.nextUrl.searchParams.get('returnTo') ?? '/dashboard';
// Verify the HMAC-SHA256 signed payload
const result = verifySignedHandoff(handoffParam, HANDOFF_SECRET, {
expectedAppKey: 'my-dashboard', // reject handoffs intended for other apps
});
if (!result.ok) {
console.warn('[handoff] Verification failed:', result.reason);
return loginError(result.reason);
}
const { payload } = result;
// payload: { staffId, email, displayName, roles, permissions, departmentIds, sessionId, issuedAt }
// Exchange the verified payload for a backend session
let backendRes: Response;
try {
backendRes = await fetch(`${API_URL}/api/auth/login/workspace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
staffId: payload.staffId,
email: payload.email,
displayName: payload.displayName,
roles: payload.roles,
permissions: payload.permissions,
departmentIds: payload.departmentIds,
sessionId: payload.sessionId,
}),
});
} catch {
return loginError('backend_unavailable');
}
if (!backendRes.ok) {
return loginError('backend_rejected');
}
// Forward the Set-Cookie headers from the backend to the browser
const redirect = NextResponse.redirect(
new URL(returnTo.startsWith('/') ? returnTo : '/dashboard', request.url),
{ status: 303 }
);
for (const cookie of backendRes.headers.getSetCookie?.() ?? []) {
redirect.headers.append('Set-Cookie', cookie);
}
return redirect;
}Express backend workspace login endpoint
Your Express backend receives the handoff payload from the Next.js route and creates a proper authenticated session using IdentityCore.
// src/routes/auth.ts
import { Router } from 'express';
import { identity } from '../identity.js';
const router = Router();
router.post('/auth/login/workspace', async (req, res, next) => {
try {
const { staffId, email, displayName, roles, permissions, departmentIds, sessionId } =
req.body as {
staffId?: string; email?: string | null;
displayName?: string | null; roles?: string[];
permissions?: string[]; departmentIds?: string[];
sessionId?: string;
};
if (!staffId) {
return res.status(400).json({ error: { code: 'bad_request', message: 'staffId is required' } });
}
// Issue a signed JWT directly from IdentityCore for this staff member
// without requiring a Better Auth browser session.
const token = await identity.issueToken(staffId);
// Verify the token resolves to an active staff member with app access
const ctx = await identity.verifyToken(token);
if (!ctx) {
return res.status(403).json({ error: { code: 'forbidden', message: 'Staff does not have access' } });
}
// Set your session cookie however your app manages sessions
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min — matches JWT expiry
res.cookie('sh_access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
expires: expiresAt,
path: '/',
});
res.json({
staffId: ctx.staffId,
roles: ctx.access.roles,
accessTokenExpiresAt: expiresAt.toISOString(),
});
} catch (err) {
next(err);
}
});
export { router as authRouter };Standalone Express app (no Next.js)
If your service is a pure API that receives handoffs directly (not via a Next.js frontend):
// src/routes/handoff.ts
import { Router } from 'express';
import { verifySignedHandoff } from '@smarthivelabs-devs/identity-sdk/handoff';
import { identity } from '../identity.js';
const HANDOFF_SECRET = process.env.WORKSPACE_HANDOFF_SECRET ?? '';
const router = Router();
router.get('/auth/handoff', async (req, res, next) => {
try {
const result = verifySignedHandoff(
req.query.workspace_handoff as string | null | undefined,
HANDOFF_SECRET,
{ expectedAppKey: 'my-service' }
);
if (!result.ok) {
return res.status(401).json({ error: { code: 'handoff_failed', reason: result.reason } });
}
const token = await identity.issueToken(result.payload.staffId);
const ctx = await identity.verifyToken(token);
if (!ctx) {
return res.status(403).json({ error: { code: 'forbidden', message: 'No app access' } });
}
// Issue your own session token here, or return the JWT directly
res.json({ token, staffId: ctx.staffId, roles: ctx.access.roles });
} catch (err) {
next(err);
}
});System Registration
registerSystem() registers (or updates) your app's entry in the SmartHive Workspace app-registry — the list that appears on the /systems launcher page. It is idempotent and safe to call on every service boot.
Requires workspaceUrl and workspaceInternalSecret in the client config (see Environment Variables).
How it works
Your service boots
│
▼
identity.registerSystem({ app_key, launch_url, launch_mode, ... })
│
│ POST /v1/internal/app-registry
│ Authorization: Internal <WORKSPACE_INTERNAL_SECRET>
▼
Workspace Backend
- upserts the row in workspace_core.app_registry
- returns the saved record
│
▼
App appears in the Workspace /systems pageWhen launch_mode: 'signed_handoff', the Workspace backend generates a time-limited signed token and appends it to the launch URL. withIdentity detects the token and routes the request to your /api/auth/handoff route automatically.
Express / NestJS — register on startup
// src/server.ts (or NestJS onModuleInit)
await identity.registerCatalog(catalog);
await identity.registerSystem({
app_key: 'my-service',
name: 'My Service',
description: 'Optional description shown in the launcher',
launch_url: 'https://my-service.smarthivelabs.dev',
launch_mode: 'signed_handoff', // or 'redirect' for public apps
required_permission: 'my-service.items.read', // omit to allow all staff
sort_order: 20, // lower = higher in the list
});Next.js — register from instrumentation.ts
See the Catalog registration on Next.js boot section for a full example. Note the NEXT_RUNTIME === 'nodejs' guard — this does not fire on Cloudflare Workers.
registerSystem options
| Field | Type | Required | Description |
|---|---|---|---|
| app_key | string | Yes | Unique identifier. Lowercase letters, numbers, hyphens, underscores only. E.g. ai-nexus-admin |
| name | string | Yes | Display name shown in the launcher |
| description | string | No | Short description (max 300 chars) |
| launch_url | string | Yes | The URL the Workspace launcher redirects to |
| launch_mode | 'redirect' \| 'signed_handoff' | Yes | signed_handoff = HMAC-signed token appended to the URL. redirect = plain redirect |
| required_permission | string | No | If set, only staff with this permission see the app in the launcher |
| active | boolean | No | Default true. Set false to hide without deleting |
| sort_order | number | No | 0–9999. Lower values appear first. Default 100 |
Handling soft failures
If the Workspace Backend is temporarily unreachable during boot, you may not want to crash the service. Set throwOnRegistrationFailure: false in the client config and log the warning instead:
export const identity = new IdentityClient({
// ...
throwOnRegistrationFailure: false,
});
// registerSystem does not have its own failure flag — wrap it yourself:
try {
await identity.registerSystem({ ... });
} catch (err) {
console.warn('[identity] System registration skipped (Workspace unreachable):', err);
}Authorization Helpers
Pure functions that run locally — no HTTP calls. Use these after you have an IdentityContext or EffectiveAccess object.
hasPermission
import { hasPermission, Scope } from '@smarthivelabs-devs/identity-sdk';
// Global permission (default when no scope is passed)
hasPermission(ctx.access, 'my-service.items.read');
// → true if staff has the permission globally
// Department-scoped
hasPermission(ctx.access, 'my-service.items.write', Scope.department('dept-abc'));
// → true if staff has the permission scoped to that department (or globally)
// App-scoped
hasPermission(ctx.access, 'my-service.admin.manage', Scope.app('my-service'));
// Module-scoped
hasPermission(ctx.access, 'my-service.reports.view', Scope.module('my-service.reports'));Note:
super_adminrole bypasses all permission checks —hasPermissionalways returnstruefor super admins.
hasRole
import { hasRole } from '@smarthivelabs-devs/identity-sdk';
hasRole(ctx.access, 'my_service_admin');
// → true if staff has the role (or is super_admin)hasAnyPermission / hasAllPermissions
import { hasAnyPermission, hasAllPermissions } from '@smarthivelabs-devs/identity-sdk';
// OR — pass if the staff holds at least one of these keys
hasAnyPermission(ctx.access, ['my-service.items.read', 'my-service.admin.manage']);
// AND — pass only if the staff holds every key
hasAllPermissions(ctx.access, ['my-service.items.read', 'my-service.items.write']);
// With a scope
hasAnyPermission(
ctx.access,
['my-service.items.read', 'my-service.admin.manage'],
Scope.department('dept-abc')
);hasAppAccess
import { hasAppAccess } from '@smarthivelabs-devs/identity-sdk';
// Staff must have access to the app in any environment
hasAppAccess(ctx.access, 'my-service');
// Staff must have access specifically in PROD
hasAppAccess(ctx.access, 'my-service', 'PROD');Scope builders
import { Scope } from '@smarthivelabs-devs/identity-sdk';
Scope.global() // { scopeType: 'GLOBAL', scopeRef: null }
Scope.department('dept-abc') // { scopeType: 'DEPARTMENT', scopeRef: 'dept-abc' }
Scope.app('my-service') // { scopeType: 'APP', scopeRef: 'my-service' }
Scope.module('my-service.reports')// { scopeType: 'MODULE', scopeRef: 'my-service.reports' }
Scope.environment('PROD') // { scopeType: 'ENVIRONMENT', scopeRef: 'PROD' }
Scope.self('staff-xyz') // { scopeType: 'SELF', scopeRef: 'staff-xyz' }Client Reference
new IdentityClient(config)
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| identityCoreUrl | string | Yes | — | Base URL of IdentityCore. No trailing slash. |
| serviceSecret | string | Yes | — | INTERNAL_SERVICE_SECRET shared with IdentityCore |
| appKey | string | Yes | — | Your app's registered key |
| env | string \| null | No | null | Environment for app access checks. Case-insensitive — 'prod', 'PROD', 'Prod' all work |
| loginUrl | string | No | — | Redirect unauthenticated browsers here (Express only) |
| cacheTtlMs | number | No | 30_000 | Token cache TTL in ms. 0 = disabled |
| throwOnRegistrationFailure | boolean | No | true | If false, registerCatalog logs a warning instead of throwing |
| workspaceUrl | string | No | — | Base URL of the Workspace Backend. Required for registerSystem() |
| workspaceInternalSecret | string | No | — | Shared secret for Workspace internal calls. Required for registerSystem() |
| fetch | typeof fetch | No | globalThis.fetch | Override for testing |
Token methods
// Verify a JWT and resolve full access context. Uses in-process cache.
const ctx: IdentityContext | null = await client.verifyToken(token);
// High-risk re-check — skips the cache, always calls IdentityCore.
// Use before destructive or privilege-sensitive actions.
const result: IntrospectResponse = await client.introspectToken(token);
// result.active, result.staffId, result.authzVersion, result.claims
// Issue a signed JWT for a staff member without a browser session.
// Calls IdentityCore's /v1/token/issue. Used for server-originated flows.
const token: string = await client.issueToken(
'staff-id-abc',
'my-service', // optional — defaults to configured appKey
'PROD' // optional — defaults to configured env
);Cache management
// Remove a single token from the cache (e.g. after logout or permission change)
client.invalidateToken(token);
// Clear the entire cache
client.clearCache();
// Inspect cache utilisation
const { size, maxSize } = client.getCacheStats();
// size: current number of cached entries
// maxSize: 2000 (hard cap)Catalog, system registration & health
// Register permissions, roles, and apps with IdentityCore. Idempotent.
const result = await client.registerCatalog(catalog);
// result.counts: { permissions, apps, roles, permissionLinks }
// Register (or update) this app's entry in the Workspace systems launcher. Idempotent.
// Requires workspaceUrl + workspaceInternalSecret in config.
const record = await client.registerSystem({
app_key: 'my-service',
name: 'My Service',
launch_url: 'https://my-service.smarthivelabs.dev',
launch_mode: 'signed_handoff', // or 'redirect'
required_permission: 'my-service.items.read',
sort_order: 10,
});
// record: { id, app_key, name, launch_url, launch_mode, active, sort_order, created_at, updated_at }
// Ping IdentityCore — useful in health check endpoints
const healthy: boolean = await client.checkHealth();Error Handling
All SDK errors extend IdentitySDKError.
import { IdentitySDKError, UnauthorizedError, ForbiddenError, StaleTokenError } from '@smarthivelabs-devs/identity-sdk';
try {
const ctx = await identity.verifyToken(token);
} catch (err) {
if (err instanceof StaleTokenError) {
// authzVersion mismatch — staff permissions changed, force re-login
console.log(`Expected v${err.expected}, got v${err.got}`);
} else if (err instanceof UnauthorizedError) {
// Token invalid or expired
} else if (err instanceof ForbiddenError) {
// Valid token but no app access
} else if (err instanceof IdentitySDKError) {
// Other SDK error — err.status, err.code, err.message, err.details
}
}Error types
| Class | Status | Code | When |
|---|---|---|---|
| UnauthorizedError | 401 | unauthorized | Missing or invalid token |
| ForbiddenError | 403 | forbidden | Valid token, no permission |
| TokenExpiredError | 401 | token_expired | JWT past its exp claim |
| StaleTokenError | 401 | stale_token | authzVersion mismatch — staff permissions changed |
| IdentitySDKError | varies | varies | Base class for all SDK errors; also thrown on IdentityCore HTTP errors |
Express global error handler
// src/middleware/errorHandler.ts
import { IdentitySDKError } from '@smarthivelabs-devs/identity-sdk';
import { ErrorRequestHandler } from 'express';
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
if (err instanceof IdentitySDKError) {
return res.status(err.status).json({
error: { code: err.code, message: err.message }
});
}
console.error(err);
res.status(500).json({ error: { code: 'internal_error', message: 'Internal server error' } });
};
// Mount last in app.ts:
app.use(errorHandler);SmartHive Dashboard Setup (Next.js + Cloudflare Workers)
Overview
Every SmartHive dashboard deployed with @opennextjs/cloudflare needs five things wired correctly. Use createCloudflareAuthMiddleware — it encodes all the runtime-specific behaviour so you never have to think about it again.
Critical constraints discovered in production (all handled automatically by the SDK):
| Constraint | Detail |
|---|---|
| external: true required | @opennextjs/cloudflare v1.3+ removed support for external: false. The middleware always runs as a separate edge Worker. |
| Set-Cookie stripped by service binding proxy | OpenNext's external middleware proxy does NOT forward Set-Cookie headers from the main app Worker's responses. Cookies MUST be set directly from the middleware Worker's own response, never from a Route Handler. |
| 4096-byte browser cookie limit | Browsers silently drop cookies over 4096 bytes. The full HandoffPayload (100+ cross-app permissions) far exceeds this. Only store fields your app needs. |
| Web Crypto in edge runtime | The middleware edge Worker cannot use node:crypto. Use crypto.subtle (Web Crypto API — available natively in Cloudflare Workers, Node.js 18+, and browsers). |
1. open-next.config.ts
Copy this exactly. The external: true + override block is required by @opennextjs/cloudflare v1.3+.
import type { OpenNextConfig } from '@opennextjs/cloudflare';
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
proxyExternalRequest: 'fetch',
incrementalCache: 'dummy',
tagCache: 'dummy',
queue: 'dummy',
},
},
edgeExternals: ['node:crypto'],
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
incrementalCache: 'dummy',
tagCache: 'dummy',
queue: 'dummy',
},
},
};
export default config;2. wrangler.jsonc
{
"name": "my-dashboard",
"main": ".open-next/worker.js",
"compatibility_date": "2025-05-05",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{ "binding": "WORKER_SELF_REFERENCE", "service": "my-dashboard" }
],
"routes": [
{ "pattern": "my-dashboard.smarthivelabs.dev", "custom_domain": true }
],
"vars": {
"NEXT_PUBLIC_APP_URL": "https://my-dashboard.smarthivelabs.dev",
"NEXT_PUBLIC_WORKSPACE_API_URL": "https://workspace.smarthivelabs.dev"
}
// Secrets — set via: wrangler secret put WORKSPACE_HANDOFF_SECRET
}3. Middleware — src/middleware.ts
This is the only file that requires any thought. Everything else is boilerplate.
import { createCloudflareAuthMiddleware } from '@smarthivelabs-devs/identity-sdk/next-session';
import type { HandoffPayload } from '@smarthivelabs-devs/identity-sdk/handoff';
export const { middleware, config } = createCloudflareAuthMiddleware({
prefix: 'myapp', // unique per dashboard — e.g. 'socialops', 'votyhive'
appKey: 'myapp', // must match the app_key in workspace_core.app_registry
// Build the minimal actor stored in the session cookie.
// KEEP THIS SMALL — browsers silently drop cookies over 4096 bytes.
// The full HandoffPayload with 100+ cross-app permissions will be dropped.
selectActor: (p: HandoffPayload) => ({
staffId: p.staffId,
email: p.email,
displayName: p.displayName,
roles: p.roles,
sessionId: p.sessionId,
expiresAt: p.expiresAt,
// Only keep permissions relevant to this app:
permissions: p.permissions.filter(x => x.startsWith('myapp.')),
}),
});createCloudflareAuthMiddleware:
- Intercepts
GET /api/auth/handoff?workspace_handoff=<token>in the edge Worker itself - Verifies the HMAC signature using Web Crypto (
crypto.subtle) — nonode:cryptoneeded - Sets session cookies on the middleware Worker's own redirect response — this is why Set-Cookie reaches the browser (the service binding proxy only strips Set-Cookie from responses proxied from the main app Worker)
- Falls through to the standard auth guard for all other routes
Reads WORKSPACE_HANDOFF_SECRET and NEXT_PUBLIC_APP_URL from process.env automatically (set as Cloudflare secrets / vars).
4. Session Route Handler — src/app/api/auth/session/route.ts
Used for logout (DELETE). Keep it for completeness.
import { type NextRequest, NextResponse } from 'next/server';
import { setSessionCookies, clearSessionCookies } from '@smarthivelabs-devs/identity-sdk/next-session';
const SESSION_OPTIONS = { prefix: 'myapp' } as const;
export async function POST(request: NextRequest): Promise<NextResponse> {
const body = await request.json() as { actor?: unknown; accessToken?: string };
if (!body.actor || !body.accessToken) {
return NextResponse.json({ error: 'Missing actor or accessToken' }, { status: 400 });
}
return setSessionCookies(
NextResponse.json({ ok: true }),
body.actor as object,
String(body.accessToken),
SESSION_OPTIONS,
);
}
export async function DELETE(): Promise<NextResponse> {
return clearSessionCookies(NextResponse.json({ ok: true }), SESSION_OPTIONS);
}5. Authed Layout — src/app/(authed)/layout.tsx
import { redirect } from 'next/navigation';
import { getActor } from '@smarthivelabs-devs/identity-sdk/next-session';
// Define the minimal type that matches what selectActor returns in middleware.ts
type MyAppActor = {
staffId: string;
email: string | null;
displayName: string | null;
roles: string[];
sessionId: string;
expiresAt: number;
permissions: string[];
};
export default async function AuthedLayout({ children }: { children: React.ReactNode }) {
const actor = await getActor<MyAppActor>({ prefix: 'myapp' });
if (!actor) redirect('/login');
return (
// your layout JSX — pass actor to sidebar/topbar
<>{children}</>
);
}getActor tries the middleware-injected x-myapp-actor request header first (set by the auth guard for authenticated routes), then falls back to reading the raw Cookie header directly. Both paths work in Cloudflare Workers.
6. Login page — src/app/login/page.tsx
export default function LoginPage() {
return (
<main>
<p>Sign in via SmartHive Workspace to access this dashboard.</p>
<a href={`${process.env.NEXT_PUBLIC_WORKSPACE_API_URL ?? 'https://workspace.smarthivelabs.dev'}/systems`}>
Open Workspace
</a>
</main>
);
}7. Environment variables
| Variable | Where | Description |
|---|---|---|
| NEXT_PUBLIC_APP_URL | wrangler.jsonc vars | Public URL of this dashboard |
| NEXT_PUBLIC_WORKSPACE_API_URL | wrangler.jsonc vars | Workspace backend URL |
| WORKSPACE_HANDOFF_SECRET | Cloudflare secret | HMAC secret — must match IDENTITY_INTERNAL_SECRET on the Workspace backend |
Set the secret:
wrangler secret put WORKSPACE_HANDOFF_SECRET8. Register the app in the Workspace launcher
Add a row to workspace_core.app_registry (via migration or the Workspace admin UI):
INSERT INTO workspace_core.app_registry
(app_key, name, launch_url, launch_mode, required_permission, active, sort_order)
VALUES
('myapp', 'My Dashboard',
'https://my-dashboard.smarthivelabs.dev/api/auth/handoff',
'signed_handoff', 'workspace.apps.myapp.launch', true, 30)
ON CONFLICT (app_key) DO UPDATE SET
launch_url = EXCLUDED.launch_url,
name = EXCLUDED.name;Auth flow (end to end)
Staff clicks "My Dashboard" in Workspace launcher
│
▼
Workspace Backend
signs HandoffPayload with IDENTITY_INTERNAL_SECRET
redirects → https://my-dashboard.smarthivelabs.dev/api/auth/handoff?workspace_handoff=<token>
│
▼
Cloudflare Edge — external middleware Worker (createCloudflareAuthMiddleware)
intercepts GET /api/auth/handoff
verifies HMAC with crypto.subtle
builds minimal actor (selectActor callback)
returns 303 redirect to /dashboard
+ Set-Cookie: myapp_actor=...; HttpOnly; Secure; SameSite=Lax; Max-Age=28800
+ Set-Cookie: myapp_access_token=...; HttpOnly; Secure; SameSite=Lax; Max-Age=28800
← response goes DIRECTLY to browser (not proxied through main app Worker)
│
▼
Browser stores cookies, follows redirect to /dashboard
│
▼
Cloudflare Edge — external middleware Worker (auth guard)
reads Cookie header → finds myapp_actor → valid + not expired
passes request to main app Worker with x-myapp-actor header
│
▼
Main app Worker — authed layout
getActor() reads x-myapp-actor header → returns actor
renders dashboard ✓Runtime notes for Cloudflare Workers (OpenNext)
| Rule | Why |
|---|---|
| Use createCloudflareAuthMiddleware, not createAuthMiddleware | The standard middleware cannot set cookies that reach the browser — the service binding proxy strips Set-Cookie from proxied responses |
| external: true + full override block required | @opennextjs/cloudflare v1.3+ removed external: false |
| Keep selectActor output under ~800 bytes JSON | Browsers silently drop cookies over 4096 bytes — no error, no warning |
| Never use cookieStore.set() from next/headers for writes | Silently drops in Workers; use response.cookies.set() on a NextResponse object |
| WORKSPACE_HANDOFF_SECRET must be a Cloudflare secret (not a var) | vars are inlined at build time; secrets are injected at runtime and not visible in logs |
Exports Map
| Import path | Exports |
|---|---|
| @smarthivelabs-devs/identity-sdk | IdentityClient, Scope, hasPermission, hasRole, hasAnyPermission, hasAllPermissions, hasAppAccess, defineCatalog, verifyToken, extractBearerToken, buildLoginRedirectUrl, decodeJwtExpClaim, errors, types (SystemRegistration, SystemRegistrationResponse, IdentitySDKConfig, IdentityContext, …) |
| @smarthivelabs-devs/identity-sdk/express | requireAuth, requirePermission, requirePermissions, requireRole, requireApp |
| @smarthivelabs-devs/identity-sdk/nextjs | withIdentity (Edge — includes workspace_handoff interception), withAuth (Node.js) |
| @smarthivelabs-devs/identity-sdk/handoff | verifySignedHandoff, buildSignedHandoffUrl, types (HandoffPayload, HandoffVerificationResult) |
| @smarthivelabs-devs/identity-sdk/next-session | createCloudflareAuthMiddleware (Cloudflare Workers), createAuthMiddleware (Node.js), setSessionCookies, clearSessionCookies, getActor, getSessionActor, getActorFromHeader, getAccessToken, types (SessionCookieOptions, AuthMiddlewareOptions, CloudflareAuthMiddlewareOptions) |
| @smarthivelabs-devs/identity-sdk/handoff-client | completeHandoff, decodeHandoffPayload, types (CompleteHandoffOptions) |
Requirements
- Node.js ≥ 20
- Module format — ships CJS (
.cjs) and ESM (.js); works withrequire()in NestJS/CommonJS projects andimportin ESM/Next.js projects - Express ≥ 4.18 — optional peer dep, only needed for
./expressimports - Next.js ≥ 14 — optional peer dep, only needed for
./nextjs,./next-session,./handoff-clientimports - cookie-parser — required if using
requireAuthwithcookieNameoption in Express
