Login
login authenticates a user with their email and password and returns a fresh JWT token plus the matching user document. It is the first act of the Auth & Users chapter because every protected act in the app requires a token header that only login can produce. It belongs to the user model and is the only user act without a preAct chain โ you don't need a token to get one.
The act lives in src/user/login/. Note that the folder is named login but the implementation files are loginUser.fn.ts / loginUser.val.ts (the folder is the act, the files are named after the function they export).
The validator (loginUser.val.ts)โ
The set holds exactly two fields: email (validated with emailPattern from @model) and password (size(string(), 8, 100) โ between 8 and 100 characters). The get is optional and lets the client ask for either a token (a short string) or a projection of the user via selectStruct("user", 1) โ depth 1 means pure fields only, no nested relations.
import { object, optional, size, string } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { emailPattern } from "@model";
export const loginUserValidator = () => {
return object({
set: object({
email: emailPattern,
password: size(string(), 8, 100),
}),
get: optional(
object({
token: optional(size(string(), 1, 1000)),
user: selectStruct("user", 1),
}),
),
});
};
Runtime-agnostic imports
"lesan" is this repo's path alias for the framework source. On npm/Bun you'd import from @hemedani/lesan, and on Deno from jsr:@hemedani/lesan. The @model / @lib / ../../../mod.ts aliases stay as they are in your project (see Project Layout).
The registration (mod.ts)โ
login is registered with no preAct โ that's what makes it the entry point. setTokens, setUser, and grantAccess don't run here because there is no token yet to verify.
import { coreApp } from "../../../mod.ts";
import { loginUserFn } from "./loginUser.fn.ts";
import { loginUserValidator } from "./loginUser.val.ts";
export const loginUserSetup = () =>
coreApp.acts.setAct({
schema: "user",
actName: "login",
fn: loginUserFn,
validator: loginUserValidator(),
});
The implementation (loginUser.fn.ts)โ
The function destructures password and email out of set, and the whole get projection. Inside it defines a small createTokenForUser helper that builds a token with _id, email, and roles, plus an exp 90 days in the future (60 * 60 * 24 * 90 seconds). The token is created by createToken from @lib (see Auth Utilities).
Because password is excluded from the model's default projections, the function forces get.user.email, get.user.password, and get.user.roles to 1 before the query so it can verify the password. It looks the user up with user.findOne({ filters: { email }, projection: get.user }), throws if no user matches, compares the submitted password against the stored hash with comparePassword, and โ if it matches โ deletes the password from the object so it never leaks into the response.
import { type ActFn } from "lesan";
import { createToken } from "@lib";
import { user } from "../../../mod.ts";
import { comparePassword } from "@lib";
import { throwError } from "@lib";
export const loginUserFn: ActFn = async (body) => {
const {
set: { password, email },
get,
} = body.details;
const createTokenForUser = async (user: any) => {
const token = await createToken({
_id: user._id,
email: user.email,
roles: user.roles,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90,
});
return { token, user };
};
get.user.email = 1;
get.user.password = 1;
get.user.roles = 1;
const foundedUser = await user.findOne({
filters: { email },
projection: get.user,
});
if (!foundedUser) {
throwError("This user does not exist at all!");
}
const passIsCorrect = await comparePassword(password, foundedUser!.password);
if (passIsCorrect) {
delete foundedUser!.password;
return await createTokenForUser(foundedUser);
} else {
throwError("Your password is incorrect!");
}
};
Two things worth calling out:
passwordis hashed with SHA-256 (seehashPasswordin Auth Utilities), and the seed script stores the SHA-256 hex ofGhostPass123!.comparePasswordre-hashes the submitted value and compares hex strings.- The app's bootstrap superuser has
isGhost: true, which lets it bypass every role/feature check ingrantAccessโ butloginitself doesn't care about roles, it only checks the password.
In the workflowโ
login is the gate for the whole app. Call it first, keep the returned token, and send it as the token header on every other user act:
- User model โ the
userschema,emailPattern,roles, and the excludedpasswordfield. - getMe โ the first act you call with the token.
- Auth Utilities โ
createToken,comparePassword,throwError. - Overview โ where this series starts.
Run itโ
The seed script (deno task seed) creates the ghost admin ghost@medsupply.io / GhostPass123!. login needs no token header:
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-d '{
"model": "user",
"act": "login",
"details": {
"set": {
"email": "ghost@medsupply.io",
"password": "GhostPass123!"
},
"get": {
"token": "t",
"user": {
"_id": 1,
"first_name": 1,
"last_name": 1,
"email": 1,
"roles": 1
}
}
}
}'
A successful response looks like:
{
"body": {
"token": "<your.jwt.token>",
"user": {
"_id": "...",
"first_name": "Ghost",
"last_name": "Admin",
"email": "ghost@medsupply.io",
"roles": [{ "roleId": "...", "name": "Manager" }]
}
},
"success": true
}
Copy body.token into a token: <jwt> header for every other user act in this chapter.
Errors & fixesโ
| Message | What it means | How to fix |
|---|---|---|
This user does not exist at all! | No user matches the submitted email (case-sensitive exact match). | Double-check the email; create the user with addUser first, or use the seeded ghost@medsupply.io. |
Your password is incorrect! | The email exists but the password doesn't hash to the stored value. | Re-type the password. If you're re-seeding, remember the seed sets the password to GhostPass123!. |
Before the fn ever runs, the superstruct validator rejects a set that fails emailPattern or has a password shorter than 8 characters โ you'll get a generic validation error from the framework rather than one of the messages above.