Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"github.copilot.chat.codeGeneration.useInstructionFiles": true,
"github.copilot.enable": {
"javascript": true,
"markdown": true
"markdown": true,
"json": true
}
}
160 changes: 160 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Migrations

Breaking changes and upgrade notes for downstream projects.

---

## `acl` → `@casl/ability` (2026-02-20)

`acl@0.4.11` (unmaintained since 2018) has been replaced by `@casl/ability`.

### What changed

- `lib/middlewares/policy.js` no longer exports `Acl`.
- Policy files now call `policy.registerRules([...])` instead of `policy.Acl.allow([...])`.
- `isAllowed` and `isOwner` middleware signatures are **unchanged** — routes do not need to be updated.

### HTTP method → CASL action mapping

| HTTP method | CASL action |
|-----------------|-------------|
| `GET` | `read` |
| `POST` | `create` |
| `PUT` / `PATCH` | `update` |
| `DELETE` | `delete` |
| `*` (all) | `manage` |

### Migration example

**Before (`acl`):**

```js
import policy from '../../../lib/middlewares/policy.js';

const invokeRolesPolicies = () => {
policy.Acl.allow([
{
roles: ['user'],
allows: [
{ resources: '/api/tasks', permissions: '*' },
{ resources: '/api/tasks/:taskId', permissions: '*' },
],
},
{
roles: ['guest'],
allows: [
{ resources: '/api/tasks/stats', permissions: ['get'] },
{ resources: '/api/tasks', permissions: ['get'] },
{ resources: '/api/tasks/:taskId', permissions: ['get'] },
],
},
]);
};

export default { invokeRolesPolicies };
```

**After (`@casl/ability`):**

```js
import policy from '../../../lib/middlewares/policy.js';

const invokeRolesPolicies = () => {
policy.registerRules([
{ roles: ['user'], actions: 'manage', subject: '/api/tasks' },
{ roles: ['user'], actions: 'manage', subject: '/api/tasks/:taskId' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/stats' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks' },
{ roles: ['guest'], actions: ['read'], subject: '/api/tasks/:taskId' },
]);
};

export default { invokeRolesPolicies };
```

### `defineAbilityFor` is now async

`policy.defineAbilityFor(user)` returns a `Promise<Ability>` (lazy-loads `@casl/ability` on first call). Express `isAllowed` middleware is `async` and works unchanged. If you test `defineAbilityFor` directly, `await` it:

```js
// Unit test
const ability = await policy.defineAbilityFor(null);
expect(ability.can('read', '/api/tasks')).toBe(true);
```

> **Jest note**: `policy.js` must be a static top-level import in the test file (not only reached via dynamic `import()`). This pre-loads the module in Jest's VM registry before policy files are dynamically imported in `beforeAll`.
> ```js
> import policy from '../../../lib/middlewares/policy.js'; // required at top level
> ```

### Steps for downstream projects

1. `npm remove acl && npm install @casl/ability`
2. Update every `modules/*/policies/*.policy.js` following the pattern above.
3. Remove any direct use of `policy.Acl` (it is no longer exported).
4. If you have unit tests that call `defineAbilityFor`, add `import policy from '...policy.js'` as a top-level static import and `await` the call.
5. Run `npm run lint && npm test` — all existing 403/200 assertions should pass unchanged.
Comment thread
PierreBrisorgueil marked this conversation as resolved.

---

## `@hapi/joi` → `zod@3` + `body-parser` / `swig` removed (2026-02-21)

`@hapi/joi` (abandoned), `body-parser` (built into Express 4.16+), `swig` and `consolidate` (template engine, unused in API-only mode) have been removed.

### What changed

- `lib/helpers/joi.js` deleted → `lib/helpers/zod.js` (zxcvbn `superRefine` helper).
- `lib/middlewares/model.js`: `getResultFromJoi(body, schema, options)` → `getResultFromZod(body, schema)` (no options arg).
- `model.isValid(schema)` middleware interface is **unchanged** — routes do not need updating.
- `config.joi` renamed to `config.validation`; `validationOptions` key removed (Zod handles stripping and defaults internally).
- PUT routes should use a `.partial()` schema (`TaskUpdate`, `UserUpdate`) for partial updates.
Comment thread
PierreBrisorgueil marked this conversation as resolved.

### Migration example

**Before (`@hapi/joi`):**

```js
import Joi from '@hapi/joi';

const TaskSchema = Joi.object().keys({
title: Joi.string().trim().default('').required(),
description: Joi.string().allow('').default('').required(),
});

export default { Task: TaskSchema };
```

**After (`zod@3`):**

```js
import { z } from 'zod';

const Task = z.object({
title: z.string().trim().min(1),
description: z.string().default(''),
}).strip();

const TaskUpdate = Task.partial();

export default { Task, TaskUpdate };
```

### Unit tests

Replace `schema.Task.validate(data, options)` with `schema.Task.safeParse(data)`. The result shape changes:

| | Joi | Zod |
|---|---|---|
| Success | `{ value: T, error: undefined }` | `{ success: true, data: T }` |
| Failure | `{ value: T, error: ValidationError }` | `{ success: false, error: ZodError }` |

Assertions like `expect(result.error).toBeFalsy()` / `.toBeDefined()` work unchanged. To verify field stripping, check `result.data?.unknownField` (not `result.unknownField`).

### Steps for downstream projects

1. `npm remove @hapi/joi body-parser swig consolidate && npm install zod@3`
2. Rewrite `modules/*/models/*.schema.js` using the Zod pattern above.
3. If you call `model.getResultFromJoi(body, schema, options)` directly, replace with `model.getResultFromZod(body, schema)`.
4. Rename `config.joi` → `config.validation` in all `config/defaults/*.js`; remove `validationOptions`.
5. Update unit tests from `.validate()` to `.safeParse()`.
6. Run `npm run lint && npm test` — all existing 422/200 assertions should pass unchanged.
24 changes: 15 additions & 9 deletions config/defaults/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ const config = {
maxSize: 126, // max password size
minimumScore: 3, // min password complexity score
},
cookie: {
secure: false, // false in dev (HTTP localhost)
sameSite: 'strict',
},
rateLimit: {
auth: {
windowMs: 15 * 60 * 1000, // 15 min
max: 20, // 20 requests per window in dev (more lenient)
message: { message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
},
},
// jwt is for token authentification
jwt: {
secret: 'WaosSecretKeyExampleToChnageAbsolutely', // secret for hash
Expand Down Expand Up @@ -189,17 +202,10 @@ const config = {
privateKeyLocation: null,
},
},
// joi is used to manage schema restrictions, on the top of mongo / orm
joi: {
// validation is used to manage schema restrictions, on the top of mongo / orm
validation: {
// enabled HTTP methods for request data validation
supportedMethods: ['post', 'put'],
// Joi validation options
validationOptions: {
abortEarly: false, // abort after the last validation error
allowUnknown: true, // allow unknown keys that will be ignored
stripUnknown: true, // remove unknown keys from the validated data
noDefaults: false, // automatically set to true for put method (update)
},
},
seedDB: {
seed: true,
Expand Down
13 changes: 13 additions & 0 deletions config/defaults/production.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export default _.merge(config.default, {
certificate: './config/sslcerts/cert.pem',
caBundle: './config/sslcerts/cabundle.crt',
},
cookie: {
secure: true, // HTTPS only in prod
sameSite: 'strict',
},
rateLimit: {
auth: {
windowMs: 15 * 60 * 1000,
max: 10, // stricter in prod
message: { message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
},
},
log: {
format: 'custom',
pattern:
Expand Down
5 changes: 5 additions & 0 deletions config/defaults/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ export default _.merge(config.default, {
},
},
},
rateLimit: {
auth: {
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
},
},
});
19 changes: 19 additions & 0 deletions lib/helpers/zod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Module dependencies
*/
import zxcvbn from 'zxcvbn';
import config from '../../config/index.js';
import { z } from 'zod';

/**
* @desc Zod superRefine for zxcvbn password strength
*/
const passwordRefinement = (val, ctx) => {
if (config.zxcvbn.forbiddenPasswords.includes(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'password is too common' });
} else if (zxcvbn(val).score < config.zxcvbn.minimumScore) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `password must have a strength of at least ${config.zxcvbn.minimumScore}` });
}
};

export default { passwordRefinement };
59 changes: 24 additions & 35 deletions lib/middlewares/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,64 +15,53 @@ const cleanError = (string) =>
.trim();

/**
* get Joi result
* get Zod result
*/
const getResultFromJoi = (body, schema, options) =>
schema.validate(body, options, (err, data) => {
if (err) {
const output = {
status: 'failed',
error: {
original: err._object,
// fetch only message and type from each error
details: _.map(err.details, ({ message, type }) => ({
message: message.replace(/['"]/g, ''),
type,
})),
},
};
return output;
}
return data;
});
const getResultFromZod = (body, schema) => {
const result = schema.safeParse(body);
if (!result.success) {
return {
error: {
original: body,
_original: body,
details: result.error.issues.map(({ message, code }) => ({
message: message.replace(/['"]/g, ''),
type: code,
})),
},
};
}
return { value: result.data };
};

/**
* check error and return if needed
*/
const checkError = (result) => {
if (result && result.error) {
if (result.error.original && (result.error.original.password || result.error.original.firstname))
if (result.error.original && (result.error.original.password || result.error.original.firstName))
result.error.original = _.pick(result.error.original, config.whitelists.users.default);
if (result.error._original && (result.error._original.password || result.error._original.firstName))
result.error._original = _.pick(result.error._original, config.whitelists.users.default);
let description = '';
result.error.details.forEach((err) => {
const message = cleanError(err.message);
description += `${message.charAt(0).toUpperCase() + message.slice(1).toLowerCase()}. `;
});

if (result.error._original && (result.error._original.password || result.error._original.firstname))
result.error._original = _.pick(result.error._original, config.whitelists.users.default);
return description;
}
return false;
};

/**
* Check model is Valid with Joi schema
* Check model is Valid with Zod schema
*/
const isValid = (schema) => (req, res, next) => {
const method = req.method.toLowerCase();
const options = _.clone(config.joi.validationOptions);
if (_.includes(config.joi.supportedMethods, method)) {
if (method === 'put') {
options.noDefaults = true;
}
// Validate req.body using the schema and validation options
const result = getResultFromJoi(req.body, schema, options);
// check error
if (_.includes(config.validation.supportedMethods, method)) {
const result = getResultFromZod(req.body, schema);
const error = checkError(result);
if (error) return responses.error(res, 422, 'Schema validation error', error)(result.error);

// else return req.body with the data after Joi validation
req.body = result.value;
return next();
}
Expand All @@ -81,7 +70,7 @@ const isValid = (schema) => (req, res, next) => {

export default {
cleanError,
getResultFromJoi,
getResultFromZod,
checkError,
isValid,
};
Loading