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
90 changes: 90 additions & 0 deletions .claude/rules/role-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,57 @@ System roles are used for **runtime checks only** and must **NEVER** be stored i
| `S_EVERYONE` | Public access | Always true |
| `S_NO_ONE` | Locked access | Always false |

### `object` means the PERSISTED object — never the request payload

For `S_SELF` and `S_CREATOR`, "object" is the record loaded from the database
(`serviceOptions.dbObject`), **not** the input DTO. This distinction is security-critical.

On the **input** path the DTO is fully attacker-controlled. Deciding `S_SELF` from it would let an
authenticated attacker unlock an owner-restricted field on someone else's record just by putting
their own id in the body — the service writes to the target it was called with, not to the id in the
payload:

```
PATCH /users/<victim-id> { "id": "<attacker-id>", "iban": "DE...attacker" }
```

On the **output** path there is no attacker-controlled input: the object being checked *is* the
persisted record (and a list is checked per item), so it is the correct comparison target there.

Both `check()` (`input.helper.ts`) and `checkRestricted()` (`restricted.decorator.ts`) implement this.
If you write your own rights check, compare against `dbObject` on input — never against the DTO.

### ⚠️ `S_CREATOR` is the CREATOR of the record — which is often an admin, not the user

`createdBy` is set by the audit plugin to whoever **created the record**. On a self-signup that is the
user themselves. But in an **invite or admin-provisioning flow it is the inviting admin** — and it
stays that way forever.

So `@Restricted(S_CREATOR)` on a **User input field** does not mean "the user may edit their own
field". It means **"whoever created this account may edit it"** — granting the inviter permanent
write access to the invited user's record.

That is almost never the intent, and it is dangerous on exactly the fields people reach for it on:

```typescript
// DANGEROUS on an invite-based system: the inviting workspace admin IS the creator, so this
// lets them rewrite the invited member's email — and then trigger a password reset.
@UnifiedField({ roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR] })
email?: string;

// SAFE: only a system admin, and the user changes their own email through the verification-gated
// BetterAuth changeEmail flow, never through a generic update DTO.
@UnifiedField({ roles: [RoleEnum.ADMIN] })
email?: string;
```

**Upgrade note:** before v11.28.x, `S_SELF`/`S_CREATOR` on an *input* field never actually fired —
the check read the claim off the DTO, and `MapAndValidatePipe` strips `id`/`createdBy` from payloads
(they are not `@UnifiedField`s). Such fields were therefore effectively **admin-only-or-denied**.
Now that the check reads the persisted object, they start working — and a field that looked
owner-restricted may suddenly become writable by an admin who provisioned the record. **Audit every
`S_SELF`/`S_CREATOR` on an input type before upgrading.**

## Critical Rule

```typescript
Expand Down Expand Up @@ -79,6 +130,45 @@ The role system is evaluated in:
- `CheckResponseInterceptor` - Filters fields based on `@Restricted()` decorators
- `CheckSecurityInterceptor` - Processes `securityCheck()` methods

## Status Codes: 401 vs 403 (v11.28.0+)

**All five permission layers answer with the same policy.** Getting this wrong has a concrete
consequence: SPA auth layers treat 401 as "session expired" and log the user out — so a mere
permission error returned as 401 kicks a logged-in user out of the whole app.

| Situation | Status | Message |
|-----------|--------|---------|
| Requester is **not authenticated** | **401** | `ErrorCode.UNAUTHORIZED` |
| Requester **is authenticated** but lacks a right | **403** | `ErrorCode.ACCESS_DENIED` |
| `S_NO_ONE` (locked for everyone) | **403 always**, even for anonymous requesters | `ErrorCode.ACCESS_DENIED` |

`S_NO_ONE` is 403 even without a session because authenticating can *never* unlock it — a 401 would
tell the client to log in and retry, which is a lie.

Exception: `ErrorCode.EMAIL_VERIFICATION_REQUIRED` is a legitimate **401** (thrown at sign-in, where
no session exists yet). Frontends must branch on the ErrorCode, not on the status alone, so this one
does not trigger the logout flow.

### Never hand-roll the decision

```typescript
import { accessDeniedException } from '@lenne.tech/nest-server';

// CORRECT — one policy, native exceptions (instanceof / @Catch keep working)
throw accessDeniedException(currentUser);
throw accessDeniedException(currentUser, 'Custom message');

// WRONG — drifts from the framework policy and mishandles falsy-but-present ids (0, '')
throw currentUser?.id ? new ForbiddenException() : new UnauthorizedException();

// WRONG — a permission error must never be a 401 for an authenticated user
throw new UnauthorizedException('Missing rights');
```

**This applies to `securityCheck()` in your models too.** `CoreTenantMemberModel` is the reference
implementation. A model that throws `UnauthorizedException` from `securityCheck()` reintroduces the
auto-logout bug in your own project.

## @Roles vs @UseGuards

**IMPORTANT: `@Roles()` already handles JWT authentication internally.**
Expand Down
19 changes: 17 additions & 2 deletions .claude/rules/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,27 @@
## Release Process

1. Make changes and ensure all tests pass (`pnpm test`)
2. Update version in `package.json`
3. Build the package (`pnpm run build`)
2. **Bump the version in BOTH version-carrying files — they must never drift:**
- `package.json` → `version`
- `spectaql.yml` → `info.version` (feeds the published GraphQL API docs)

`spectaql.yml` is derived from `package.json` by `extras/update-spectaql-version.mjs`, but that
script only runs via `pnpm run docs` — it is **not** part of `pnpm run build`. A release commit
that bumps only `package.json` therefore ships API docs advertising the previous version. Run
`node extras/update-spectaql-version.mjs` (or edit the file) and commit both together.
3. Build the package (`pnpm run build`) — this also regenerates `FRAMEWORK-API.md` with the new version
4. Publish to npm
5. Update and test in [nest-server-starter](https://github.com/lenneTech/nest-server-starter)
6. Commit changes to starter with migration notes

### Version Consistency Check

```bash
# Both must print the same version
grep -m1 '"version"' package.json
grep -m1 '^ version:' spectaql.yml
```

## Package Distribution

- **NPM Package**: `@lenne.tech/nest-server`
Expand Down
28 changes: 27 additions & 1 deletion FRAMEWORK-API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @lenne.tech/nest-server — Framework API Reference

> Auto-generated from source code on 2026-07-15 (v11.27.7)
> Auto-generated from source code on 2026-07-15 (v11.28.0)
> File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code

## CoreModule.forRoot()
Expand Down Expand Up @@ -283,6 +283,31 @@ Generic: `CrudService<Model, CreateInput, UpdateInput>`
| `tus` | README, CHECKLIST | `src/core/modules/tus/` |
| `user` | — | `src/core/modules/user/` |

## Errors & Status Codes

One 401/403 policy across all permission layers (role guards, tenant guard, `check()`,
`checkRestricted()`, model `securityCheck()`):

| Situation | Status | Message |
|-----------|--------|---------|
| Requester is **not authenticated** | **401** | `ErrorCode.UNAUTHORIZED` |
| Requester **is authenticated** but lacks a right | **403** | `ErrorCode.ACCESS_DENIED` |
| `S_NO_ONE` (locked for everyone, even admins) | **403 always** | `ErrorCode.ACCESS_DENIED` |

Never hand-roll the decision — use `accessDeniedException(user)`. It returns the **native**
`ForbiddenException` / `UnauthorizedException`, so `instanceof` checks and `@Catch(...)` filters
in consuming projects keep working.

### Exported error helpers

| Export | Purpose | Path |
|--------|---------|------|
| `ExpiredRefreshTokenException` | Exception for expired refresh token | `src/core/modules/auth/exceptions/expired-refresh-token.exception.ts` |
| `ExpiredTokenException` | Exception for expired token | `src/core/modules/auth/exceptions/expired-token.exception.ts` |
| `InvalidTokenException` | Exception for invalid token | `src/core/modules/auth/exceptions/invalid-token.exception.ts` |
| `LegacyAuthDisabledException` | Exception thrown when Legacy Auth endpoints are accessed but disabled | `src/core/modules/auth/exceptions/legacy-auth-disabled.exception.ts` |
| `accessDeniedException()` | Creates the access error that matches the requester's auth state (RFC 9110, mirrors RolesGuard): | `src/core/common/exceptions/access-denied.exception.ts` |

## Key Source Files

| File | Purpose |
Expand All @@ -293,6 +318,7 @@ Generic: `CrudService<Model, CreateInput, UpdateInput>`
| `src/core/common/services/crud.service.ts` | CrudService base class |
| `src/core/common/services/config.service.ts` | ConfigService (global) |
| `src/core/common/decorators/` | @Restricted, @Roles, @CurrentUser, @UnifiedField |
| `src/core/common/exceptions/` | accessDeniedException — the 401/403 policy |
| `src/core/common/interceptors/` | CheckResponse, CheckSecurity, ResponseModel |
| `docs/REQUEST-LIFECYCLE.md` | Complete request lifecycle |
| `.claude/rules/` | Detailed rules for architecture, security, testing |
39 changes: 39 additions & 0 deletions docs/REQUEST-LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,41 @@ The `process()` method in `ModuleService` is the **primary** way to handle CRUD
+---------------------------------------------------------------+
```

### Status Codes: 401 vs 403 (v11.28.0+)

One policy across **all five** permission layers — the role guards, the tenant guard, `check()` /
`checkRights`, `checkRestricted()` (object and field level), and a model's `securityCheck()`:

| Situation | Status | Thrown by |
|-----------|--------|-----------|
| Requester is **not authenticated** | **401** `ErrorCode.UNAUTHORIZED` | guards, `accessDeniedException(undefined)` |
| Requester **is authenticated** but lacks a right | **403** `ErrorCode.ACCESS_DENIED` | guards, `accessDeniedException(user)` |
| Resource is locked via `S_NO_ONE` | **403**, always — even for anonymous requesters | guards, `check()` |

`S_NO_ONE` is 403 for everyone because authenticating can never unlock it; a 401 would tell the
client to retry after logging in, which is a lie.

**Why this matters:** SPA auth layers commonly treat 401 as "session expired" and clear the session
(the `@lenne.tech/nuxt-extensions` auth interceptor patches `$fetch`/`fetch` globally and does exactly
this). A permission error answered with 401 therefore logs the user out of the whole app. With this
policy a frontend may treat 401 as "session invalid" — with one exception:
`ErrorCode.EMAIL_VERIFICATION_REQUIRED` is a legitimate 401 (no session exists yet at sign-in) that
must **not** trigger a logout. Branch on the ErrorCode, not on the status alone.

**Writing new denial code:** use the exported factory rather than hand-rolling the decision. It
returns the **native** `ForbiddenException` / `UnauthorizedException`, so `instanceof` checks and
`@Catch(...)` filters in consuming projects keep working:

```typescript
import { accessDeniedException } from '@lenne.tech/nest-server';

// In a service, a custom guard, or a model's securityCheck():
throw accessDeniedException(currentUser);
```

See `src/core/common/exceptions/access-denied.exception.ts` and
`migration-guides/11.27.7-to-11.28.0.md`.

### Depth-Based Optimization (v11.23.0+)

When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
Expand Down Expand Up @@ -1095,6 +1130,10 @@ Controls who can access a resolver/controller method. Evaluated by the RolesGuar

Controls who can see or modify specific properties. Evaluated by `CheckResponseInterceptor` (output) and `checkRights()` (input).

On **output** a denied field is silently removed (no exception). On **input** the request is
rejected: **403** for an authenticated requester, **401** for an anonymous one, and **403 always**
for `S_NO_ONE` — see [Status Codes: 401 vs 403](#status-codes-401-vs-403-v11280) above.

```typescript
export class User extends CorePersistenceModel {
// Only admins or the user themselves can see the email
Expand Down
Loading