Skip to content
11 changes: 9 additions & 2 deletions .claude/skills/create-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,19 @@ Example: `config.billing.plans` used in both `billing.subscription.model.mongoos

> Reference: `modules/users/models/users.schema.js` uses `z.enum(config.whitelists.users.roles)`.

### 5. Apply renames carefully
### 5. Module autonomy

- Everything the module needs lives inside `modules/{module}/` — policies, config, routes, tests, doc
- Module registers its own capabilities (subjects, abilities, config) via exports — auto-discovered by the core
- **NEVER modify shared files** (`lib/middlewares/`, `lib/services/`, `config/`) to add module-specific logic
- If the module needs authorization: create `policies/{module}.policy.js` with ability builder + subject registration exports

### 6. Apply renames carefully

- Case-sensitive, whole-word matches where possible
- Show plan before applying if many files affected
- Don't rename unrelated code (e.g., "tasks" in comments about other features)

### 6. Verify & report
### 7. Verify & report

Run `/verify`, then report: module path, renamed tokens, lint/test results, next steps (customize schema/services — routes auto-discovered via `modules/*/routes/*.js`).
31 changes: 21 additions & 10 deletions .claude/skills/feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ description: Implement a new feature or modify existing functionality. Use when
- Which module? Default to **ONE** unless justified.
- **If the module doesn't exist** → run `/create-module` to scaffold it first, then continue.

### 2. Analyze flows & edge cases
### 2. Module boundaries

- All new code isolated inside the target module (`modules/{module}/`)
- No modifications to shared core files (`lib/middlewares/`, `lib/services/`, `config/`)
- Module registers its own capabilities via exports (policies, subjects, config) — auto-discovered by the core
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated

### 3. Analyze flows & edge cases

For each user-facing flow this feature creates or modifies, identify:

Expand All @@ -22,13 +28,13 @@ For each user-facing flow this feature creates or modifies, identify:
- **Retry edge** — can the user retry after failure/rejection? (check unique indexes)
- **Multi-user impact** — who else is affected? Do they need notification?

### 3. Check boilerplate resilience
### 4. Check boilerplate resilience

- Works WITHOUT mailer configured? (graceful skip, no crash)
- Works WITHOUT organizations enabled?
- No hard dependency on external services for core flow?

### 4. Present plan & ask questions
### 5. Present plan & ask questions

**STOP and present to the user:**
- Flows identified (happy + error + edge cases)
Expand All @@ -39,7 +45,7 @@ For each user-facing flow this feature creates or modifies, identify:

## Phase 1 — Implementation

### 5. Apply layer rules
### 6. Apply layer rules

Strict order — never skip or reverse:

Expand All @@ -51,14 +57,14 @@ Routes → Controllers → Services → Repositories → Models
- **Services**: Business logic, call repositories, throw `AppError`
- **Repositories**: Database only — sole layer importing mongoose

### 6. Apply modularity rules
### 7. Apply modularity rules

- Isolate inside module boundary
- No cross-module imports unless justified (shared code → `lib/helpers/`)
- **No cross-module Repository/Model imports** — a service must never import another module's repositories or models; use the target module's Service instead
- Follow `/naming` conventions

### 7. Handle notifications
### 8. Handle notifications

If an action affects another user:
- Use `lib/helpers/mailer/` abstraction (never nodemailer directly)
Expand All @@ -68,7 +74,7 @@ If an action affects another user:

## Phase 2 — Definition of Done

### 8. Self-review checklist
### 9. Self-review checklist

**Edge cases:**
- [ ] "Last one" handled (last owner can't leave/be demoted)
Expand All @@ -83,6 +89,11 @@ If an action affects another user:
- [ ] New enum values added to ALL schema definitions (Mongoose model `enum`, Zod `z.enum`, tests)
- [ ] Grep existing enum values to find all locations before committing

**Module autonomy:**
- [ ] No changes to shared files (`lib/middlewares/`, `lib/services/`, `config/`)
- [ ] Module self-registers capabilities (subjects, abilities) via policy exports
- [ ] New routes/middleware defined inside the module boundary

**Modularity:**
- [ ] Isolated in ONE module (or justified)
- [ ] No cross-module Repository/Model imports (use target module's Service)
Expand All @@ -95,10 +106,10 @@ If an action affects another user:
**Error documentation:**
- [ ] If a non-obvious bug was fixed, add a single-line entry to `ERRORS.md` (root of repo) using format: `[YYYY-MM-DD] <scope>: <wrong> -> <right>` (see existing examples in `ERRORS.md`)

### 8b. Elegance check
### 9b. Elegance check

For non-trivial changes: pause and ask yourself "is there a simpler or more elegant approach?" If the current implementation feels hacky, refactor before proceeding.

### 9. Run `/verify`
### 10. Run `/verify`

### 10. Run `/pull-request`
### 11. Run `/pull-request`
3 changes: 3 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ description: Run quality loop (audit + lint + tests) to verify code quality, cor
- Silent error swallowing (catch + console.log)
- Config in wrong location, missing or orphaned files
- Cross-module import violations
- Module-specific logic added to shared files (`lib/middlewares/`, `lib/services/`, `config/`)
- Module not self-registering its capabilities (subjects, abilities, config)
- Cross-module dependencies that should use the target module's service instead
- Inconsistent API response formats
- Missing null/undefined checks on async returns
- Fix all issues found before proceeding
Expand Down
21 changes: 21 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ Breaking changes and upgrade notes for downstream projects.

---

## Decentralized Policy Subject Resolution (2026-04-03)

Subject resolution in `lib/middlewares/policy.js` is now registry-based instead of hardcoded. Each module's policy file exports a `*SubjectRegistration()` function that registers its own document-level and path-level subjects during `discoverPolicies()`.

### What changed

- `resolveSubject()` iterates `documentSubjectRegistry` instead of hardcoded if/else chain
- `deriveSubjectType()` iterates `pathSubjectRegistry` instead of hardcoded if/else chain
- New exports: `registerDocumentSubject`, `registerPathSubject`
- New helper: `lib/helpers/authorize.js` — simple middleware for route-level CASL checks
- Each module policy file now exports a `*SubjectRegistration({ registerDocumentSubject, registerPathSubject })` function

### Action for downstream

1. Run `/update-stack` to pull the change
2. If you have custom modules with policy files, add a `*SubjectRegistration()` export following the pattern in any existing module (e.g. `modules/tasks/policies/tasks.policy.js`)
3. `policy.isAllowed` continues to work unchanged — no route file modifications needed
4. Optional: use `authorize(action, subject)` from `lib/helpers/authorize.js` for simple route guards
Comment thread
PierreBrisorgueil marked this conversation as resolved.

---

## Audit GDPR Flags (2026-03-26)

New config flags to control IP and User-Agent capture in audit logs for GDPR compliance.
Expand Down
14 changes: 14 additions & 0 deletions lib/helpers/authorize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Simple middleware helper for route-level CASL authorization.
* Usage: router.get('/tasks', authorize('read', 'Task'), controller.list);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
*
* @param {string} action - CASL action (read, create, update, delete)
* @param {string} subject - CASL subject type string
* @returns {Function} Express middleware
*/
const authorize = (action, subject) => (req, res, next) => {
if (req.ability && req.ability.can(action, subject)) return next();
return res.status(403).json({ type: 'error', message: 'Forbidden' });
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

export default authorize;
112 changes: 61 additions & 51 deletions lib/middlewares/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,46 @@ const methodToAction = {
*/
const abilitiesRegistry = [];

/**
* Registry of document-level subject resolvers.
* Each entry maps a req property to a CASL subject type string.
* Modules register themselves via registerDocumentSubject() during startup.
* @type {Array<{reqProperty: string, subjectType: string}>}
*/
const documentSubjectRegistry = [];

/**
* Registry of path-level subject resolvers.
* Each entry maps a route path (exact or prefix) to a CASL subject type string.
* Modules register themselves via registerPathSubject() during startup.
* Entries are matched in registration order; first match wins.
* @type {Array<{routeMatch: string|Function, subjectType: string}>}
*/
const pathSubjectRegistry = [];
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* Register a document-level subject resolver.
* When a request property (e.g. 'task') is present on req, the corresponding
* subject type (e.g. 'Task') is used for CASL document-level checks.
* @param {string} reqProperty - Property name on the Express req object
* @param {string} subjectType - CASL subject type string
* @param {Function} [guard] - Optional guard function(req) => boolean; when provided,
* the entry only matches if guard returns true (allows excluding certain routes)
*/
const registerDocumentSubject = (reqProperty, subjectType, guard) => {
documentSubjectRegistry.push({ reqProperty, subjectType, guard });
};

/**
* Register a path-level subject resolver.
* Used for collection-level CASL checks when no document is loaded.
* @param {string|Function} routeMatch - Exact route path string, or a function(routePath) => boolean
* @param {string} subjectType - CASL subject type string
*/
const registerPathSubject = (routeMatch, subjectType) => {
pathSubjectRegistry.push({ routeMatch, subjectType });
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Lazy CASL loader — dynamic import avoids Jest's experimental VM module linker
let _casl = null;

Expand Down Expand Up @@ -114,20 +154,16 @@ const normalizeForCasl = (doc) => {

/**
* Resolve the CASL subject type string from a loaded document on the request.
* Maps known req properties to their CASL subject type name.
* Iterates the documentSubjectRegistry populated by module policy files.
* Documents are normalized so populated references become bare ObjectIds.
* @param {Object} req - Express request object
* @returns {{ subjectType: string, document: Object }|null} subject info or null
*/
const resolveSubject = (req) => {
if (req.task) return { subjectType: 'Task', document: normalizeForCasl(req.task) };
if (req.upload) return { subjectType: 'Upload', document: normalizeForCasl(req.upload) };
// For user admin routes, the loaded user is stored in req.model by userByID param middleware
if (req.model) return { subjectType: 'UserAdmin', document: normalizeForCasl(req.model) };
if (req.membershipDoc) return { subjectType: 'Membership', document: normalizeForCasl(req.membershipDoc) };
// Billing routes use req.organization for context but authorize via route-derived subjects
if (req.organization && !req.route?.path?.startsWith('/api/billing')) {
return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
for (const { reqProperty, subjectType, guard } of documentSubjectRegistry) {
if (!req[reqProperty]) continue;
if (guard && !guard(req)) continue;
return { subjectType, document: normalizeForCasl(req[reqProperty]) };
}
return null;
};
Expand Down Expand Up @@ -164,54 +200,22 @@ const isAllowed = async (req, res, next) => {
return responses.error(res, 403, 'Unauthorized', 'User is not authorized')();
};

/**
* Mapping of exact route paths to CASL subject types for account-related routes.
* Self-service routes use 'UserAccount', admin routes use 'UserAdmin',
* and the base '/api/users' uses 'UserSelf' to split update/delete (user).
* @type {Object<string, string>}
*/
const USER_ROUTE_SUBJECTS = {
'/api/users/me': 'UserAccount',
'/api/users/terms': 'UserAccount',
'/api/users/password': 'UserAccount',
'/api/users/avatar': 'UserAccount',
'/api/users/data': 'UserAccount',
'/api/users/data/mail': 'UserAccount',
'/api/users/stats': 'UserStats',
'/api/users': 'UserSelf',
};

/**
* Derive a CASL subject type string from an Express route path.
* Maps known route path prefixes to their corresponding subject type.
* User routes are split into several subject types:
* - 'UserAccount' for self-service (me, terms, password, avatar, data)
* - 'UserSelf' for the base /api/users path (user update/delete own account + admin list)
* - 'UserAdmin' for admin management (get/update/delete by userId, pagination)
* Iterates the pathSubjectRegistry populated by module policy files.
* Each entry's routeMatch can be an exact string, or a function(routePath) => boolean.
* First match wins.
* @param {string} routePath - Express route path (e.g. '/api/tasks' or '/api/users/me')
* @returns {string|null} CASL subject type or null if not mappable
*/
const deriveSubjectType = (routePath) => {
if (routePath === '/api/billing/checkout') return 'BillingCheckout';
if (routePath === '/api/billing/portal') return 'BillingPortal';
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
if (routePath === '/api/billing/usage') return 'BillingUsage';
if (routePath === '/api/billing/plans') return 'BillingPlans';
if (routePath.startsWith('/api/audit')) return 'AuditLog';
if (routePath.startsWith('/api/tasks')) return 'Task';
if (routePath.startsWith('/api/uploads')) return 'Upload';
if (routePath.startsWith('/api/home')) return 'Home';
if (routePath === '/api/admin/readiness') return 'Readiness';
if (routePath.startsWith('/api/admin/organizations')) return 'Organization';
if (routePath.includes('/requests')) return 'Membership';
if (routePath.includes('/members')) return 'Membership';
if (routePath.startsWith('/api/organizations')) return 'Organization';
// Check exact account route matches first
if (USER_ROUTE_SUBJECTS[routePath]) return USER_ROUTE_SUBJECTS[routePath];
// Account routes fallback
if (routePath.startsWith('/api/users')) return 'UserAccount';
// Admin user routes (/:userId, /page/:userPage)
if (routePath.startsWith('/api/admin/users')) return 'UserAdmin';
for (const { routeMatch, subjectType } of pathSubjectRegistry) {
if (typeof routeMatch === 'function') {
if (routeMatch(routePath)) return subjectType;
} else if (routeMatch === routePath) {
return subjectType;
}
}
return null;
};

Expand All @@ -235,6 +239,10 @@ const discoverPolicies = async (policyPaths) => {
} else if (normalizedKey.endsWith('abilities')) {
entry.abilities = value;
}
// Call subject registration functions exported by module policy files
if (normalizedKey.endsWith('subjectregistration')) {
value({ registerDocumentSubject, registerPathSubject });
}
}
// Also support old invokeRolesPolicies pattern during transition — skip if new-style exports found
if (entry.abilities || entry.guestAbilities) {
Expand All @@ -248,6 +256,8 @@ const discoverPolicies = async (policyPaths) => {

export default {
registerAbilities,
registerDocumentSubject,
registerPathSubject,
defineAbilityFor,
isAllowed,
discoverPolicies,
Expand Down
9 changes: 9 additions & 0 deletions modules/audit/policies/audit.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
* Audit ability definitions for CASL document-level authorization.
*/

/**
* Register audit-related subjects for path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function auditSubjectRegistration({ registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerPathSubject((p) => p.startsWith('/api/audit'), 'AuditLog');
}

/**
* Define audit-related abilities for an authenticated user.
* Only platform admins can read audit logs.
Expand Down
14 changes: 14 additions & 0 deletions modules/billing/policies/billing.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
* Billing ability definitions for CASL document-level authorization.
*/

/**
* Register billing-related subjects for path-level resolution.
* Billing uses exact route matches since each route maps to a distinct subject.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export function billingSubjectRegistration({ registerPathSubject }) {
registerPathSubject('/api/billing/checkout', 'BillingCheckout');
registerPathSubject('/api/billing/portal', 'BillingPortal');
registerPathSubject('/api/billing/subscription', 'BillingSubscription');
registerPathSubject('/api/billing/usage', 'BillingUsage');
registerPathSubject('/api/billing/plans', 'BillingPlans');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Define billing-related abilities for an authenticated user.
* Any authenticated user with an organization membership can manage billing.
Expand Down
10 changes: 10 additions & 0 deletions modules/home/policies/home.policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
* Home ability definitions for CASL document-level authorization.
*/

/**
* Register home-related subjects for path-level resolution.
* @param {Object} registry - Subject registration helpers
* @param {Function} registry.registerPathSubject - Register route path → subject type
*/
export function homeSubjectRegistration({ registerPathSubject }) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
registerPathSubject((p) => p.startsWith('/api/home'), 'Home');
registerPathSubject('/api/admin/readiness', 'Readiness');
}

/**
* Define home-related abilities for an authenticated user.
* All authenticated users can read home content (releases, changelogs, team, pages).
Expand Down
Loading
Loading