Signed-in people of your own kind
Let people who are not staff sign in through your plugin — a shop's customers, a site's members — without ever reaching the admin.
Alpha (
0.x) —@experimental. Plugin-defined kinds of signed-in person are new. Recognising a person on a request works today; access rules, the Direct API acting as a person, and audit records naming one are still to come. Pin your versions.
Nextly's own sign-in is for staff: the people who use the admin. A plugin that signs in anyone else — a shop's customers, a site's members — registers its own kind of signed-in person. Your plugin owns everything about that kind's sessions: sign-up, sign-in, cookies, password resets. Core only asks your plugin who is signed in, and only where your code asks for that kind.
Such a person:
- never reaches the admin, core's REST API, or any route that does not ask for their kind;
- holds no role and no permission, and cannot be given one;
- is never taken for a staff user, even when their id matches one.
Register a kind
import { definePlugin, type PrincipalKindDefinition } from "@nextlyhq/plugin-sdk";
type Account = { verified: boolean; plan: string | null };
const account: PrincipalKindDefinition<Account> = {
name: "account",
attributes: { verified: "boolean", plan: "string" },
async resolve(request) {
const session = await readMySession(request); // your plugin's own session
if (!session) return null;
return {
id: session.accountId,
attributes: { verified: session.verified, plan: session.plan },
};
},
};
export default definePlugin({
name: "@acme/accounts",
// ...
contributes: { principals: [account] },
});Core names the kind <plugin>:<name>, where <plugin> is your plugin's slug:
@acme/accounts registers acme-accounts:account. That full name is how a
route and server code ask for it.
| Field | Rules |
|---|---|
name | A lowercase letter, then up to 31 lowercase letters, digits or dashes. |
attributes | Every attribute the kind exposes, with its type: "boolean", "string" or "number". Typed from your attribute type, so verified: "string" does not compile for a boolean. |
resolve(request) | Who of this kind is signed in on the request, or null. |
lookup(id) | Optional. The person with this id, for running as them without a request. |
The app refuses to start on a kind whose name, attributes or resolver cannot be used, or whose full name another plugin's kind already holds.
What resolve must answer
nullfor nobody. A resolver that throws also counts as nobody. The throw is logged with the error's name only, never its message, because a message can quote the token it failed to read.- An id that is a non-empty string of at most 128 characters, unique within the kind, and never reused once the person is deleted. An id outside those bounds counts as nobody, and a warning says why.
- Every declared attribute, each with a value of its declared type or
nullfor "not known". A missing attribute or a value of the wrong type counts as nobody. An attribute you did not declare is dropped.
resolve reads cookies and headers. It is handed the request without its body,
so reading the body there cannot take it from your handler.
Core calls resolve only where an entry point asks for the kind, and at most
once per request, whoever asks: the route, its handler and any server code
asking again all see one answer. No staff path ever calls it.
Serve the person from a route
Add principal to a route:
{
method: "GET",
path: "/me",
principal: { kind: "acme-accounts:account" },
handler: async (req, ctx) => {
// ctx.principal is the signed-in account; ctx.user and ctx.caller are null.
return Response.json({ id: ctx.principal?.id });
},
}A route that asks for a kind reads neither the staff session nor an API key. Its
handler sees the person or nobody, never staff, even in a browser that is signed in
to the admin as well. ctx.auth.currentUser answers null there too.
- With
requiredleft at its default (true), a request with no such person is refused with401 AUTH_REQUIRED, the same answer any route gives an unauthenticated caller. - With
required: false, the handler runs, andctx.principalisnullwhen nobody is signed in. principalcannot be combined withpublicorrequiredPermission: the app refuses to start on either pair, and on a kind no plugin registers.
Writes from another site
A person's writes take the route's cross-site check
exactly as a staff session cookie's do: the origin by default, a token with
csrf: true, and a refusal with csrf: false. A request carrying an
Authorization: Bearer header is exempt, because a page on another site cannot
make a browser send one.
Leave csrf at its default for routes your site's pages call. csrf: true
asks for core's admin token, whose cookie lives on /admin, so a page outside the
admin cannot send it. If your plugin wants a token on its own forms, mint and
check one in the handler.
Ask from server code
From your plugin's own code, ctx.principals reaches your own kinds by the name
you gave them. It is present when your plugin registers a kind:
const account = await ctx.principals?.fromRequest(request, "account");From the app's own server code, nextly.principals takes the full kind:
const nextly = await getNextly({ config });
const account = await nextly.principals.fromRequest(request, "acme-accounts:account");Both share the request's answer with every other entry point asking for the same
kind. Both answer null to a write from another site, as ctx.auth.currentUser
does, unless the request carries a Bearer token. Asking for a kind nobody
registered throws, rather than answering nobody.
A person is not a staff user
A person's id never goes where a staff user's id goes. Do not pass it as a
userId, an actorUserId, or to ctx.auth.completeLogin: completeLogin signs
in a staff account, and a person's id there names whichever staff user happens
to hold the same id. Keep your people in your own collections, keyed by the
person's kind and id.
Stability
Everything here is @experimental until the feature is complete. These are meant
to become stable: a definition's fields and their meaning, the <plugin>:<name>
naming, what resolve must answer, the id bounds, a route's principal with
required, ctx.principal, fromRequest, Principal and PrincipalRef. The
contributes.principals key, where nextly.principals hangs, and the spelling of
a route's principal may still change.
Auth extensibility
Extend authentication from a plugin — custom strategies, external sign-in (OAuth/SSO), auth-flow hooks, and first-class multi-step challenges (2FA) — without forking core.
Lifecycle, dependencies & order
How and when a plugin runs — the setup/init/destroy lifecycle, load order, declaring dependencies on other plugins, version compatibility, and enabling/disabling.