Next.js
permdock/next wires one explicit server factory into Server Components, Server Actions, Route Handlers and the client, built for Next.js 16.3 Cache Components and Instant Navigations.
Purpose
permdock/next is the full-stack adapter for Next.js 16.3 and later (the next peer range is >=16.3); its client half, permdock/next/client, holds PermissionBoundary. One createPermDock call in a server-only file returns the async server API (getPermDock, getPermission, requireAccess), a Server Component provider that serialises the snapshot for permdock/react, and a Route Handler for the batched decision endpoint. The design goals come from Next.js 16.3 Instant Navigations: permission-gated UI must land in the prefetched App Shell, never block a navigation, and refresh when roles change. The end-to-end pattern for a multi-org app is the Next.js Cache Components guide.
API
// src/permdock/server.ts (server-only)
import "server-only";
import { cookies } from "next/headers";
import { createPermDock } from "permdock/next";
import { policy } from "@/policy";
export const {
getPermDock,
getPermission,
requireAccess,
PermDockProvider,
permdockHandler,
} = createPermDock(policy, {
subject: async () => getUser(await cookies()),
tenant: async () => (await cookies()).get("org")?.value, // active tenant; accepted only when a membership matches
onDenied: () => redirect("/forbidden"), // runs on assert and requireAccess refusals
});The active tenant usually comes from the URL (app/[org]/...); a Server Component or Route Handler passes it explicitly with getPermDock({ tenant: params.org }), and the resolver above is the fallback for requests without a segment. Either way the value is a request, not a fact: core keeps it only when principal.memberships holds that tenant, otherwise tenant-scoped checks are denied with no-membership (tenancy).
// app/[org]/layout.tsx: never awaits; hooks under it answer pending until the snapshot streams in
import { PermDockProvider } from "@/permdock/server";
export default function Layout({ children }) {
return (
<PermDockProvider include={[permissions.post, permissions.billing]}>
{children}
</PermDockProvider>
);
}
// app/posts/[id]/page.tsx (RSC), a Server Action or a Route Handler
import { getPermDock, getPermission } from "@/permdock/server";
const permdock = await getPermDock();
permdock.assert(permissions.post.update, post);
const { allowed } = await getPermission(permissions.post.update, post);
await requireAccess({
permission: permissions.post.update,
data: post,
tenant: org,
}); // forbidden() / unauthorized() on a denial
// app/api/permdock/route.ts: decision endpoint for closure grants
import { permdockHandler } from "@/permdock/server";
export const { POST } = permdockHandler();
// Client components import from permdock/react, never from permdock/next
import { usePermission, useTenant, useFilter, Protected } from "permdock/react";| Export | Role |
|---|---|
getPermDock | Async. Resolves the subject once per request through React.cache, awaits io(), builds the request-scoped PermDock, returns it. A redirect(), notFound() or other Next.js interrupt thrown by the subject or tenant resolver propagates (unstable_rethrow); any other throw makes the subject anonymous. Accepts { tenant } to set the active tenant from a route segment; (await getPermDock()).tenant(id) derives another instance without re-resolving. Safe to call from layouts, pages, Server Actions and Route Handlers; concurrent calls in one render share the same instance. |
getPermission | Async counterpart of usePermission: returns allowed, status: 'ready' and the Decision. getPermission(permission, data, { tenant }) checks in one tenant, as getPermDock({ tenant }) does. The server counterparts of the other hooks are methods on the instance: .tenants(), .memberships(), .heldRoles(), .assignableRoles(), .filter(), .actions(). |
requireAccess | Async guard for pages, layouts below the root, Server Actions and Route Handlers: requireAccess({ permission, data?, tenant?, onDenied? }) resolves to the granted Decision. A refusal first runs onDenied, the call's or else the factory's, which may redirect() or throw to replace the default; then a denial calls unauthorized() when the subject is anonymous and forbidden() otherwise, so app/unauthorized.tsx or app/forbidden.tsx renders (both need experimental.authInterrupts). approval-required throws PermDockApprovalRequiredError with the replay-safe token, and a boundary failure throws PermDockValidationError, as assert does. on('denied') hooks and the sink still see the decision. Call it in the render path and await it: a try/catch around it swallows the interrupt unless the catch calls unstable_rethrow. |
PermDockProvider | Server Component that never awaits. It starts the snapshot (optionally scoped with include, with tenant for the active tenant and tenants: 'all' to ship every membership's grants) and renders the client PermDockProvider from permdock/react with snapshotPromise, endpoint and tenant. defaults and messages pass through to it as nodes and message records only, because a function cannot cross from a Server Component to the client (provider defaults). endpoint on the factory or the provider defaults to /api/permdock; endpoint: false is snapshot-only: no permdockHandler route, and checks the snapshot cannot answer are denied with reason server-only (snapshot-only mode). The static shell renders around it, and hooks and <Protected> under it answer status: 'pending' instead of suspending, so they stay in the shell; suspend makes them suspend until the snapshot resolves. A subject resolver that throws yields the anonymous subject. Any other failure rejects the snapshot promise: hooks answer server-only, and with suspend the error reaches the nearest error boundary, so a redirect() from subject still redirects. The snapshot is not cached: for prefetchable permission UI, use the private-cache pattern below. |
getSnapshot | await getSnapshot({ tenant?, include?, tenants?, tags? }) inside an app-owned 'use cache: private' function: builds the session's snapshot, then calls cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(sub), ...tags) in that scope. The app still writes the directive. Outside a cache scope it throws an error that names the directive. |
cacheLifeFor | cacheLifeFor(snapshot, { min, max, now }) returns { stale } for cacheLife(): max (300) when the snapshot never expires, otherwise the seconds until expiresAt clamped to min (30). Below min it returns the real remainder, so Next.js leaves a nearly expired snapshot out of prefetches. It reads issuedAt from the snapshot, not the clock. |
permdockHandler | Returns POST (and GET for .well-known discovery) implementing the AuthZEN-shaped decision endpoint on top of the same subject resolver; also serves refresh({ tenant }) snapshot requests, answering from the subject's memberships. A batch over 256 evaluations is a 413 (Problem Details). |
tenant, memberships, customRoles, store, sink | The shared adapter options: how the active tenant is resolved, a MembershipSource, a RoleSource, an ApprovalStore and a DecisionSink (adapters, extension interfaces). Every sink.write that returns a promise, and one sink.flush() per render, are handed to after(), so a serverless function stays alive until decision events are delivered; outside a request scope (tests, scripts) the sink behaves as in core. |
otel, wrap | otel takes (permdock) => withOtel(permdock, options) from permdock/otel. wrap is a PermDockWrap applied to each instance after otel and before the factory onDenied; build it with wrapPermDock so tenant(), team() and derive() stay wrapped (extend PermDock). pdp and webBotAuth are not options here: getPermDock has no Request to verify and no protect to decide remotely. A Route Handler that needs them uses permdock/server. |
PermissionBoundary
requireAccess replaces a whole segment with forbidden.tsx. When only part of a page is gated, and the rest should keep rendering, let the check throw and catch it with PermissionBoundary from permdock/next/client, a 'use client' entry built on catchError from next/error:
// app/[org]/quotes/[id]/page.tsx (Server Component)
import { PermissionBoundary } from "permdock/next/client";
<PermissionBoundary
denied={<p>Only staff can delete quotes.</p>}
approval={<ApprovalNotice />}
>
<Suspense fallback={null}>
{/* awaits getPermDock(), then permdock.assert(permissions.quote.delete, quote) */}
<DeleteZone params={params} />
</Suspense>
</PermissionBoundary>;
// approval-notice.tsx
("use client");
import { usePermissionBoundary } from "permdock/next/client";
export function ApprovalNotice() {
const state = usePermissionBoundary(); // { outcome, permission, token?, retry }
if (state?.outcome !== "approval-required") return null;
return (
<button onClick={() => state.retry()}>I have approval, try again</button>
);
}- It recognises
PermDockDeniedErrorandPermDockApprovalRequiredErrorby theirdigest(errors), which is the only part of a Server Component error that reaches the client in production. Every other error, andforbidden(),notFound()andredirect(), passes to the next boundary up. deniedrenders for a denial;approvalfor an approval request, defaulting todenied. Each is a React node, which a Server Component can pass, or, from a Client Component, a function of theusePermissionBoundary()state:approval={(state) => <RequestAccess token={state.token} />}.usePermissionBoundary()inside a fallback returns the permission key, the approvaltokenfor a request-access flow, andretry(), which re-fetches and re-renders the boundary's children, for example after the approval is granted or a role changes.- Put a
Suspenseinside the boundary: during server rendering an error goes to the nearestSuspense, and the boundary catches it when the client renders. - The boundary never decides; the check that threw did. Keep the enforcement in the Server Action as well.
Active tenant from a root segment
When the organization is the root segment (app/[org]/layout.tsx is the root layout), the getter next/root-params generates for it is already a tenant resolver, so deep components need neither props nor getPermDock({ tenant }):
import { org } from "next/root-params";
export const { getPermDock, getPermission, requireAccess } = createPermDock(
policy,
{
subject: async () => getUser(await cookies()),
tenant: org,
},
);Root parameters are readable only in Server Components: in a Server Action or Route Handler the getter throws, the resolver yields no tenant, and tenant-scoped checks deny. Pass { tenant } explicitly there. With Cache Components, the root layout also needs generateStaticParams returning at least one value.
Segment configs and links
| Where | What | Why |
|---|---|---|
next.config.ts | cacheComponents: true, partialPrefetching: true, experimental.authInterrupts: true | The App Shell carries the private-cached snapshot; requireAccess needs the interrupts |
| Pages whose gated UI must paint on click | export const instant = true | Next.js validates in development that nothing on the way blocks; the gates come from the private cache |
| A shared layout that must block (it awaits request data outside a private cache) | export const instant = false on the layout, true on the pages below | Navigation between the pages stays validated while entry into the layout may block |
A rarely visited admin page that calls requireAccess at the top | export const prefetch = 'force-disabled' (and instant = false) | A runtime prefetch costs a server render per page view; the page checks access at request time anyway |
| Links to a resource page | <Link prefetch={true}> with the page's check in a 'use cache: private' function keyed on the resource id | The per-link prefetch resolves params and the private cache, so the gated actions render before the click |
A capability token in the URL (?token=) is URL data: a <Link prefetch={true}> resolves it in the prefetch, so every such link costs a server render, and a shared 'use cache' scope must never read it, because its output would be served to everyone. Verify the token inside 'use cache: private' or at request time, and link to token pages with the default prefetch.
Why an explicit factory file and not a Next plugin
The next.config.ts plugin (createPermDockPlugin) exists only as a build hook for permdock collect; it never wires runtime API. Runtime wiring lives in src/permdock/server.ts because:
- The file is the single import boundary between server and client. It can carry
import 'server-only', so a client component importinggetPermDockfails at build time (permix issue 49 leaked Prisma and Better Auth into the browser bundle through a middleware callback). - Types flow from the returned object, not from module augmentation. Two policies, or a test double, can coexist.
- Same shape in Vite, Expo and Hono: one file, one
createPermDock. See Next.js plugin.
Request lifecycle
- A request or RSC render starts. The first call to
getPermDock,getPermissionorPermDockProviderruns thesubjectresolver insideReact.cache; the result is memoised for the rest of the render. Server Actions and Route Handlers get their own instance per invocation. - Snapshot. For permission UI that should be prefetched, the app owns the cache directive. PermDock never adds
'use cache'and never readsheaders()orcookies()itself;getSnapshotruns the factory'ssubjectresolver and sets the lifetime and tag inside the app's private cache:
// src/permdock/snapshot.ts
import { getSnapshot } from "./server";
export async function loadSnapshot(org: string) {
"use cache: private";
// cacheLife(cacheLifeFor(snapshot)): 30 s minimum for per-link prefetch, 300 s for the App Shell
// cacheTag(snapshotTag(sub), ...tags): permdock:<sub>, or permdock:anon
return getSnapshot({ tenant: org, include: [permissions.post] });
}
// app/[org]/layout.tsx: synchronous, so the layout itself is static
import { PermDockProvider } from "permdock/react";
export default function Layout({ children, params }) {
return (
<PermDockProvider
snapshotPromise={params.then(({ org }) => loadSnapshot(org))}
>
{children}
</PermDockProvider>
);
}Without the factory, call snapshotFor(policy, user, { tenant }) and set cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(user?.id)) yourself, as getSnapshot does. The tenant is part of the cache key because it is an argument, so /acme and /globex shells carry different snapshots; a subject that does not belong to the segment's tenant gets an empty tenant scope, never another tenant's grants.
'use cache: private' caches per browser session, so session-derived output is allowed into the prefetched App Shell. Anything that reads cookies() without it must render behind a Suspense boundary or the navigation blocks. 3. Guards resolve from the shell. Client Protected and usePermission answer from the prefetched snapshot, so a link to an admin page paints on click. Data-dependent checks (getPermission(permissions.post.update, post) where post comes from the database) sit under Suspense and stream. 4. Mutations. A Server Action calls permdock.assert(...) or await requireAccess(...), performs the write, then updateTag(snapshotTag(user)) (the tag your loader set) if the write changed roles, memberships or custom roles, which invalidates the private cache and the client refetches. A Route Handler, such as a billing webhook, an entitlement webhook or a revocation-counter bump, uses revalidateTag(tag, { expire: 0 }) instead, because updateTag works only in Server Actions. Not revalidateTag(tag, 'max'): stale-while-revalidate would keep serving the revoked grant for one more visit. refresh() is for a Server Action that changed nothing cached but must repaint the acting browser. A role-assignment action additionally checks permissions.member.assignRole and that the new role is in permdock.assignableRoles() before writing (tenancy). 5. Closure grants. usePermission posts batched evaluations to app/api/permdock/route.ts; permdockHandler resolves the same subject, validates the posted data at the boundary and answers. 6. Segments that must not be prefetched with permission UI export instant = false (see segment configs).
OpenAPI
Next.js has no built-in spec generator, so permdock/next has no in-process OpenAPI hook. The recipe is a producer plus PermDock's Overlay:
- next-openapi-gen scans
app/api/**/route.ts, generates anoperationIdper handler, and scaffolds Scalar as the docs UI. permdock openapi emit --format overlaywritespermdock.overlay.jsonwithsecuritySchemes, per-operationsecurityandx-permdock-*for every handler that callsgetPermDock().assert(...)or is wrapped bypermdockHandler(CLI: openapi).- next-openapi-gen's
overlay.applymerges the Overlay before writing the spec, so Scalar, any Arazzo files it compiles, and SDK generators such as@hey-api/openapi-tssee the applied description.
// openapi-gen.config.ts
export default defineConfig({
openapi: "3.2.0",
overlay: { apply: ["./permdock.overlay.json"] },
});permdock openapi emit --doc public/openapi.json --format overlay --out permdock.overlay.json --check # CI
pnpm exec openapi-gen generatenext-openapi-gen accepts Overlay 1.0 to 1.2, so this recipe is where --overlay 1.2 (the pinned Overlay 1.2 draft with reusable actions, OpenAPI Overlay) can be used first; the default 1.1 output works identically.
Rules: operationId is the join key, and --check fails on a handler without one. Do not use next-openapi-gen's @auth JSDoc tag or authPresets on handlers PermDock covers; two sources of security on one operation is the failure mode the recipe exists to avoid. PermDock owns security; the producer owns paths and schemas. The Overlay is the documented default here because the description is generated on every build (Overlay).
MCP route
A Next.js app exposes an MCP server as a route handler through mcp-handler; permdock/mcp guards it with the same policy and the same subject resolver the rest of the app uses (MCP adapter, Hosting). This is the route the PermDock Cloud template on the Vercel Marketplace exposes (Cloud adapter).
// app/api/mcp/route.ts
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { createPermDock } from "permdock/mcp";
import { createJwtSubjectResolver } from "permdock/jwt";
import { policy, permissions } from "@/permissions";
import { store } from "@/permdock"; // the same ApprovalStore the server components use
const verify = createJwtSubjectResolver({
issuer: process.env.AUTH_ISSUER!,
audience: process.env.MCP_RESOURCE!,
});
const { protectServer } = createPermDock(policy, {
subject: (authInfo) => authInfo.extra?.subject ?? null,
store,
});
const handler = createMcpHandler((server) => {
const guarded = protectServer(server);
guarded.registerTool(
"delete_post",
{ permission: permissions.post.delete, inputSchema, data: loadPost },
deletePost,
);
});
const authed = withMcpAuth(
handler,
async (_req, token) => {
const subject = await verify(token);
if (!subject.principal) return undefined;
return {
token,
clientId: subject.claims.client_id,
scopes: subject.claims.scope?.split(" ") ?? [],
expiresAt: subject.expiresAt,
extra: { subject },
};
},
{ required: true },
);
export { authed as GET, authed as POST };Three rules. The subject comes from the verified bearer token, never from the app session: an MCP client is not a browser and carries no cookie. store must be durable on Vercel because each invocation is stateless (memoryApprovalStore() would drop a pending approval; permdock doctor warns). And the route is unrelated to permdockHandler(), which serves the client decision endpoint for the React side; the two can share one policy file and nothing else. mcp-handler also mounts on Nuxt, SvelteKit and Hono with the same file, so the recipe is not Next-specific.
What it validates and how denials surface
The shared adapter contract applies; the Next.js specifics:
includemust reference groups or resources from the policy's definition; unknown references are a type error.permdock/nextis server-only:createPermDockthrows when it runs wheredocumentexists. Keep it in a server-only file; the definition module stays client-safe.assertruns the layered handlers: the per-call handler, instanceon('denied')hooks, thenonDeniedfrom the factory. PermDock picks no default page behaviour: the app'sonDeniedcallsredirect()ornotFound(), and without oneassertthrowsPermDockDeniedError.PermDockApprovalRequiredErrorcarries the replay-safetokenso an approval page can call the action again.getPermissionnever throws a PermDock error; a failure to build the instance returns ano-grantdenial. Next.js interrupts fromsubjectortenant(redirect(),notFound(), request-time bailouts) pass through, as fromgetPermDock.requireAccessthrows only the Next.js interrupts and the twoasserterrors above.- Route Handlers using
permdockHandleranswer with Problem Details; client components behave as in React. - The subject always comes from the factory's
subjectresolver;getPermDockandrequireAccessaccept only atenant. A Route Handler authenticated by a bearer token instead of cookies usespermdock/serverwith a token resolver. The active tenant stays explicit (getPermDock({ tenant }), thetenantprop, or thetenantresolver); PermDock never reads a[org]segment on its own. Only the App Router is supported.
Next.js 16.3 functions
How permdock/next relates to each function in the Next.js functions reference. "Adapter" means permdock/next calls it; "app" means the app calls it next to PermDock.
| Function | Who | Use |
|---|---|---|
io | adapter | Awaited before the instance is created, because decisions read the clock (membership and token expiry). It suspends a prerender without blocking prefetches and resolves at once inside a cache scope |
connection | never | It would hold every permission check until a real request, which blocks prefetches |
after | adapter | Keeps sink writes and flush() alive after the response |
unstable_rethrow | adapter | Lets redirect() and other interrupts from the subject and tenant resolvers through |
forbidden, unauthorized | adapter | requireAccess on a denial |
cookies, headers | app | Read by the app's subject resolver or inside the app's 'use cache: private' loader; PermDock never calls them |
cacheLife, cacheTag | app | cacheLife(cacheLifeFor(snapshot)) and cacheTag(snapshotTag(sub)) inside the app's private cache |
updateTag | app | In the Server Action that changed roles, memberships or custom roles |
revalidateTag | app | In Route Handlers (webhooks), with { expire: 0 } |
refresh | app | Server Actions only; other members' browsers need an app signal that calls router.refresh() |
next/root-params | app | The generated getter is a tenant resolver (above) |
generateStaticParams | app | Required for a root segment under Cache Components |
notFound, redirect, permanentRedirect | app | notFound() hides a row the subject may not read; redirect() from onDenied |
catchError | adapter | PermissionBoundary in permdock/next/client (above) |
useOffline | app | An offline badge; snapshot-backed <Protected> and usePermission keep answering offline |
useRouter, useParams, usePathname, useSearchParams, useSelectedLayoutSegment(s), useLinkStatus | app | Plain navigation UI; never a source of subject or tenant facts |
unstable_cache, unstable_noStore, revalidatePath, fetch, draftMode | not relevant | 'use cache' and tags replace them; no decision makes a network call |
generateMetadata, generateImageMetadata, generateSitemaps, generateViewport, ImageResponse, NextRequest, NextResponse, userAgent, useReportWebVitals | not relevant | No permission surface; permdockHandler speaks the standard Request and Response |
Plain Node
permdock/next and permdock/next/client also load without a bundler: in Vitest, which runs dependencies as plain Node ES modules, and in Node scripts. next publishes no exports map, so the entries import next/cache.js, next/server.js and next/error.js with their file extension. next/navigation goes through the package import #next/navigation: under the react-server condition it stays the bare next/navigation, which Next.js maps to its server build, and elsewhere it resolves to next/navigation.js. Turbopack and webpack builds are unchanged.
Example app
apps/examples/next is a field-service app on named scopes: organizations (acme, globex) with staff roles, and customers inside them whose portal contacts see only their own quotes. It turns on cacheComponents, partialPrefetching, experimental.authInterrupts and experimental.useOffline.
src/permdock/server.tscallscreatePermDockfrompermdock/next;src/lib/access.tsholds the app-owned caches:loadSnapshot(private,cacheLifeFor),visibleQuotes(private,permdock.filter), andquoteAccess(private, keyed on the quote id).- The staff quote page wraps a request-time delete check in
PermissionBoundary: a member sees an approval notice with a retry button, a contact sees the denied fallback, and the quote itself keeps rendering. /[org]pages and the portal exportinstant = true;/[org]/settingsexportsprefetch = 'force-disabled'andinstant = falseand callsrequireAccess.- Quote links use
<Link prefetch={true}>; Server Actions callrequireAccessand thenupdateTag.
To check a route by hand, open the Next.js DevTools in next dev, select Navigation Inspector, turn on Pause on navigations, then refresh (the static shell) or click a link (the prefetched UI) and confirm the gated nav and actions are already there. Prefetching happens only under next start, so confirm the result with pnpm --filter @permdock/example-next build and start.
apps/examples/next-better-supabase is the same shape on Supabase sessions from better-supabase: /[locale]/[orgSlug]/... routes, staff roles in memberships, portal contacts through contacts.user_id, and the PermDock access-token hook and RLS helpers generated from one set of sources. It runs in snapshot-only mode (endpoint={false}, no /api/permdock route), loads the snapshot and the RLS-filtered rows through bs.cached(), and tags both with snapshotTag(sub) (the recipe). pnpm --filter @permdock/example-next-better-supabase serve starts Postgres in testcontainers, so it needs Docker.
Why
- The app owns every cache directive. Whether a value may be shared (
'use cache') or only per session ('use cache: private'), how long it lives and which tags bust it depend on where the app reads the session and what it mutates. A directive inside the package would hide those choices. Sopermdock/nextreturns plain values (cacheLifeFor,snapshotFor), and the example shows where'use cache: private'goes. io(), neverconnection(). Both keep the clock read out of the static shell, butconnection()waits for a real request and so blocks every prefetch of a gated page.io()suspends only the prerender and resolves at once inside a cache scope, which is where prefetchable permission UI lives.{ expire: 0 }, not'max', for revocations.'max'is Next.js's recommended stale-while-revalidate default for content; for a permission it means one more visit with the revoked grant.- No
scopeFromRootParams()export.next/root-paramsgenerates one getter per root segment, named after the folder, so a library cannot import it. The getter is already atenantresolver. PermissionBoundaryreads a digest. A client boundary sees a Server Component error in production only through itsdigest, and Next.js keeps a digest the error already carries. So the denial and approval errors set a stable one in core, where every adapter throws them, and the boundary parses it instead of matching messages or class names. It is an error boundary, not a gate: a second way to hide UI on a client-side check would compete with<Protected>.endpoint: false, not a missing route. Without the option, a closure grant read byusePermissionposts to/api/permdock, and an app that never added the route gets a 404 per check and apendingthat resolves to a denial over the network.falsemakes the choice explicit, keeps the browser from making the request at all, and gives the denial its own reason so a test can assert it.permdock doctorPD044 warns when a hook reads such a grant and neither a route nor an endpoint exists.requireAccesstakes an object. A collection permission has no row, and a positionalrequireAccess(p, undefined, { tenant })placeholder is easy to misread; the object form states the tenant where it is used.
Related standards
- AuthZEN: decision endpoint shapes served by
permdockHandler. - Problem Details: Route Handler denials.
- Standard Schema: boundary validation.
- OpenAPI 3.2 and OpenAPI Overlay: the next-openapi-gen recipe above.
- Design rationale: Next.js 16.3 Instant Navigations, the collect extraction model, explicit factory over a plugin, composing with the OpenAPI toolchain and the ecosystem index.
Last updated on
Solid
permdock/solid maps the snapshot-backed provider, hook and guard onto Solid context, signal accessors and a component.
Server kernel
permdock/server is the Fetch-first kernel every HTTP and RPC adapter wraps; it resolves the subject from a Request, scopes one PermDock per request, runs protect, emits Problem Details and exposes the OpenAPI hook contract.