@lenne.tech/nest-server 11.41.3 → 11.41.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/role-system.md +15 -1
  4. package/.claude/rules/testing.md +16 -4
  5. package/CLAUDE.md +6 -3
  6. package/FRAMEWORK-API.md +4 -1
  7. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -1
  8. package/dist/core/common/helpers/logging.helper.js +2 -0
  9. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  10. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -1
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -1
  12. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +2 -2
  13. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +46 -6
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  15. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +7 -1
  16. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  17. package/dist/core/modules/api-token/core-api-token.constants.d.ts +6 -0
  18. package/dist/core/modules/api-token/core-api-token.constants.js +11 -0
  19. package/dist/core/modules/api-token/core-api-token.constants.js.map +1 -0
  20. package/dist/core/modules/api-token/core-api-token.decorators.d.ts +1 -0
  21. package/dist/core/modules/api-token/core-api-token.decorators.js +8 -0
  22. package/dist/core/modules/api-token/core-api-token.decorators.js.map +1 -0
  23. package/dist/core/modules/api-token/core-api-token.helpers.d.ts +127 -0
  24. package/dist/core/modules/api-token/core-api-token.helpers.js +398 -0
  25. package/dist/core/modules/api-token/core-api-token.helpers.js.map +1 -0
  26. package/dist/core/modules/api-token/core-api-token.middleware.d.ts +10 -0
  27. package/dist/core/modules/api-token/core-api-token.middleware.js +58 -0
  28. package/dist/core/modules/api-token/core-api-token.middleware.js.map +1 -0
  29. package/dist/core/modules/api-token/core-api-token.model.d.ts +20 -0
  30. package/dist/core/modules/api-token/core-api-token.model.js +199 -0
  31. package/dist/core/modules/api-token/core-api-token.model.js.map +1 -0
  32. package/dist/core/modules/api-token/core-api-token.module.d.ts +11 -0
  33. package/dist/core/modules/api-token/core-api-token.module.js +39 -0
  34. package/dist/core/modules/api-token/core-api-token.module.js.map +1 -0
  35. package/dist/core/modules/api-token/core-api-token.registry.d.ts +9 -0
  36. package/dist/core/modules/api-token/core-api-token.registry.js +17 -0
  37. package/dist/core/modules/api-token/core-api-token.registry.js.map +1 -0
  38. package/dist/core/modules/api-token/core-api-token.service.d.ts +104 -0
  39. package/dist/core/modules/api-token/core-api-token.service.js +550 -0
  40. package/dist/core/modules/api-token/core-api-token.service.js.map +1 -0
  41. package/dist/core/modules/auth/guards/roles.guard.js +17 -1
  42. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  43. package/dist/core/modules/better-auth/better-auth-roles.guard.js +12 -2
  44. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth.middleware.js +4 -0
  46. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  47. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +4 -0
  48. package/dist/core/modules/better-auth/core-better-auth.module.js +18 -0
  49. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  50. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  51. package/dist/core/modules/migrate/migration-runner.js +3 -0
  52. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  53. package/dist/core/modules/tenant/core-tenant-guard.registry.d.ts +2 -0
  54. package/dist/core/modules/tenant/core-tenant-guard.registry.js +19 -0
  55. package/dist/core/modules/tenant/core-tenant-guard.registry.js.map +1 -0
  56. package/dist/core/modules/tenant/core-tenant.guard.d.ts +1 -0
  57. package/dist/core/modules/tenant/core-tenant.guard.js +28 -12
  58. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  59. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +1 -0
  60. package/dist/core/modules/tenant/core-tenant.helpers.js +19 -0
  61. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  62. package/dist/core/modules/tenant/core-tenant.module.d.ts +5 -2
  63. package/dist/core/modules/tenant/core-tenant.module.js +8 -0
  64. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  65. package/dist/core/modules/user/core-user.service.js +7 -0
  66. package/dist/core/modules/user/core-user.service.js.map +1 -1
  67. package/dist/core.module.js +6 -0
  68. package/dist/core.module.js.map +1 -1
  69. package/dist/index.d.ts +7 -0
  70. package/dist/index.js +7 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/tsconfig.build.tsbuildinfo +1 -1
  73. package/docs/REQUEST-LIFECYCLE.md +49 -1
  74. package/docs/security-overrides.md +21 -13
  75. package/migration-guides/11.41.3-to-11.41.4.md +172 -0
  76. package/migration-guides/11.41.4-to-11.41.5.md +144 -0
  77. package/package.json +35 -34
  78. package/src/core/common/helpers/graceful-shutdown.helper.ts +9 -0
  79. package/src/core/common/helpers/logging.helper.ts +7 -0
  80. package/src/core/common/helpers/process-diagnostics.helper.ts +4 -0
  81. package/src/core/common/interfaces/server-options.interface.ts +122 -6
  82. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +17 -10
  83. package/src/core/modules/ai/README.md +9 -1
  84. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +74 -7
  85. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +13 -1
  86. package/src/core/modules/api-token/INTEGRATION-CHECKLIST.md +121 -0
  87. package/src/core/modules/api-token/README.md +212 -0
  88. package/src/core/modules/api-token/core-api-token.constants.ts +27 -0
  89. package/src/core/modules/api-token/core-api-token.decorators.ts +29 -0
  90. package/src/core/modules/api-token/core-api-token.helpers.ts +711 -0
  91. package/src/core/modules/api-token/core-api-token.middleware.ts +57 -0
  92. package/src/core/modules/api-token/core-api-token.model.ts +193 -0
  93. package/src/core/modules/api-token/core-api-token.module.ts +48 -0
  94. package/src/core/modules/api-token/core-api-token.registry.ts +53 -0
  95. package/src/core/modules/api-token/core-api-token.service.ts +822 -0
  96. package/src/core/modules/auth/guards/roles.guard.ts +23 -2
  97. package/src/core/modules/better-auth/better-auth-roles.guard.ts +18 -4
  98. package/src/core/modules/better-auth/core-better-auth.middleware.ts +8 -0
  99. package/src/core/modules/better-auth/core-better-auth.module.ts +33 -0
  100. package/src/core/modules/migrate/README.md +15 -7
  101. package/src/core/modules/migrate/migration-runner.ts +9 -0
  102. package/src/core/modules/tenant/README.md +17 -0
  103. package/src/core/modules/tenant/core-tenant-guard.registry.ts +36 -0
  104. package/src/core/modules/tenant/core-tenant.guard.ts +52 -12
  105. package/src/core/modules/tenant/core-tenant.helpers.ts +30 -0
  106. package/src/core/modules/tenant/core-tenant.module.ts +19 -2
  107. package/src/core/modules/user/core-user.service.ts +14 -0
  108. package/src/core.module.ts +12 -0
  109. package/src/index.ts +12 -0
@@ -0,0 +1,121 @@
1
+ # API Tokens Integration Checklist
2
+
3
+ ## Reference Implementation
4
+
5
+ - Module: `node_modules/@lenne.tech/nest-server/src/core/modules/api-token/` (README.md explains the model)
6
+ - Management controller as a project writes it: `tests/api-token.e2e-spec.ts` → `ApiTokenAdminController`
7
+ (in the GitHub repository; not shipped in the npm package)
8
+
9
+ ## Required Steps
10
+
11
+ ### 1. Enable the feature
12
+
13
+ **Edit:** `src/config.env.ts`
14
+
15
+ ```typescript
16
+ apiTokens: {
17
+ scopes: ['upload', 'read', 'export'],
18
+ encryptionKey: process.env.API_TOKEN_ENCRYPTION_KEY, // or SECRETS_ENCRYPTION_KEY
19
+ },
20
+ ```
21
+
22
+ **WHY the key:** the signing key of every token (for signed assertions) is stored encrypted with it.
23
+ The boot fails in production/staging without one. Rotating it invalidates all signing keys.
24
+
25
+ **WHY the scopes:** a token can only carry scopes from this list, and a route can only be opened for
26
+ them. With an empty list no token can be created (a boot warning says so).
27
+
28
+ ### 2. Open the routes a token may call
29
+
30
+ ```typescript
31
+ @ApiTokenScopes('upload')
32
+ @Roles(RoleEnum.S_USER)
33
+ @Post('documents')
34
+ ```
35
+
36
+ **WHY explicitly:** tokens are denied on every route without `@ApiTokenScopes()`, public ones
37
+ included. Open only what a machine needs; never the routes that change credentials, email, roles or
38
+ memberships.
39
+
40
+ ### 3. Add management endpoints
41
+
42
+ The core ships the service, the project owns the routes (same split as tenant members):
43
+
44
+ ```typescript
45
+ @Controller('api-tokens')
46
+ @Roles(RoleEnum.S_USER)
47
+ export class ApiTokenController {
48
+ constructor(private readonly apiTokens: CoreApiTokenService) {}
49
+
50
+ @Post('tenant') // X-Tenant-Id header selects the tenant
51
+ createTenantToken(@CurrentTenant() tenantId: string, @Body() input: any, @CurrentUser() user: any) {
52
+ return this.apiTokens.createTenantToken(tenantId, input, user);
53
+ }
54
+
55
+ @Post('mine')
56
+ createUserToken(@Body() input: any, @CurrentUser() user: any) {
57
+ return this.apiTokens.createUserToken(input, user);
58
+ }
59
+ // find…/update…/revoke…/delete… analogously
60
+ }
61
+ ```
62
+
63
+ **WHY no rights logic here:** the service checks who may act (owner, `manageRole`, platform admin)
64
+ and refuses token-authenticated callers. Forward `@CurrentUser()` unchanged.
65
+
66
+ **WHY `@HttpCode(200)` on revoke routes:** Nest answers a `POST` with 201 by default.
67
+
68
+ ### 4. Clean up with your own entities
69
+
70
+ ```typescript
71
+ await this.apiTokenService.deleteAllForTenant(tenantId); // when deleting a tenant
72
+ await this.apiTokenService.deleteAllForUser(userId); // when deleting a user
73
+ await this.apiTokenService.revokeAllForUser(userId); // after an account compromise
74
+ ```
75
+
76
+ **WHY:** the core has no tenant model to hook into, so nothing calls these for you.
77
+
78
+ - **For TENANT tokens `deleteAllForTenant()` is what ends access.** A tenant token belongs to the
79
+ tenant, not to a person: removing members does not touch it (that is the point — it survives staff
80
+ changes), and the core cannot see a tenant being deleted. Until you call it (or `revokeTenantToken()`),
81
+ a deleted or off-boarded tenant's tokens keep authenticating with the lowest tenant role.
82
+ - **For USER tokens** access already ends when the user or the membership disappears; the call only
83
+ removes the rows.
84
+
85
+ ### 5. Optional: bind tokens to project data
86
+
87
+ Extend the model and register it:
88
+
89
+ ```typescript
90
+ @Schema({ timestamps: true })
91
+ export class ApiToken extends CoreApiTokenModel {
92
+ @UnifiedField({ isOptional: true, mongoose: { type: String } })
93
+ exportConfigId: string = undefined;
94
+ }
95
+
96
+ CoreModule.forRoot(envConfig, { apiToken: { model: ApiToken } });
97
+ ```
98
+
99
+ Extra fields of a create/update input are stored as given (protected fields excepted). Read them from
100
+ `getApiTokenContext(user).tokenId` + your own lookup.
101
+
102
+ ## Verification Checklist
103
+
104
+ - [ ] Build succeeds (`pnpm run build`), tests pass (`pnpm test`)
105
+ - [ ] A created token's plaintext appears in the create response only — never in a list
106
+ - [ ] `GET` on a route WITHOUT `@ApiTokenScopes()` with a token → 403 (also for a public route)
107
+ - [ ] A revoked token → 401 on every route
108
+ - [ ] A tenant token with a foreign `X-Tenant-Id` → 403
109
+ - [ ] A user token of an admin cannot call an ADMIN route
110
+ - [ ] Production config sets `apiTokens.encryptionKey` (or `SECRETS_ENCRYPTION_KEY`)
111
+
112
+ ## Common Mistakes
113
+
114
+ | Mistake | Symptom | Fix |
115
+ | --------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- |
116
+ | Route not opened | Token gets 403 everywhere | `@ApiTokenScopes('<scope>')` on method or class |
117
+ | Scope missing from `apiTokens.scopes` | 400 "Unknown scope(s)" on create | Add it to the vocabulary |
118
+ | `manageRole` not a tenant role / hierarchy with one level | Boot error | Declare a tenant role; keep a role below it, or `tenantTokens: false` |
119
+ | No encryption key in production | Boot error | Set `apiTokens.encryptionKey` |
120
+ | Shipping the token to a browser for an embedded page | Long-lived credential exposed | Mint a signed assertion server-side (README → "Signed assertions") |
121
+ | Expecting a token on a GraphQL subscription | 401 / anonymous | Tokens are HTTP-only |
@@ -0,0 +1,212 @@
1
+ # API Tokens
2
+
3
+ Bearer credentials for machine clients, scripts and embedded pages that cannot carry a session cookie
4
+ (an iframe inside a third-party application gets no `SameSite=Lax` cookie). Opt-in via `apiTokens`;
5
+ without it nothing changes.
6
+
7
+ | | USER token | TENANT token |
8
+ | ------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------- |
9
+ | Belongs to | one user | one tenant — survives staff changes |
10
+ | Managed by | its owner | members holding `apiTokens.manageRole`, platform admins |
11
+ | Acts with | its user's **current** rights, **without global roles** (ADMIN, `globalOnlyRoles`) | the **lowest** tenant role, never a global role |
12
+ | Tenants | the user's active memberships — optionally restricted to ONE | its own tenant only |
13
+ | Optional limits | scopes, one tenant, `maxTenantRole`, expiry | scopes, expiry |
14
+ | Needs multi-tenancy | no | yes |
15
+
16
+ Both kinds are **denied on every route** that does not declare `@ApiTokenScopes(...)`, public
17
+ routes included. Tokens never manage tokens.
18
+
19
+ ## Configuration
20
+
21
+ ```typescript
22
+ // config.env.ts
23
+ apiTokens: {
24
+ scopes: ['upload', 'read', 'export'], // the vocabulary routes and tokens use
25
+ encryptionKey: process.env.API_TOKEN_ENCRYPTION_KEY, // required in production/staging
26
+ // prefix: 'ltt', // tokens read ltt_…, assertions ltts_…
27
+ // manageRole: 'owner', // default: highest role of multiTenancy.roleHierarchy
28
+ // maxAssertionLifetimeSeconds: 900,
29
+ // rateLimit: { max: 600, windowSeconds: 60 }, // per token; false switches it off
30
+ // userTokens: true,
31
+ // tenantTokens: true, // only takes effect with multiTenancy
32
+ }
33
+ ```
34
+
35
+ `true` / `{}` enable with defaults, `{ enabled: false }` pre-configures. Full reference: `IApiTokens`
36
+ in `server-options.interface.ts`. The boot fails on an invalid prefix or scope, on a `manageRole` that
37
+ is not a declared tenant role, on a hierarchy in which the lowest role (a tenant token's role) reaches
38
+ the manage role, and in production/staging without an encryption key.
39
+
40
+ ## Opening a route
41
+
42
+ ```typescript
43
+ @ApiTokenScopes('upload') // any ONE of the listed scopes; method replaces class
44
+ @Roles(RoleEnum.S_USER)
45
+ @Post('documents')
46
+ async upload(@CurrentUser() caller: any) {
47
+ const token = getApiTokenContext(caller); // undefined for a session / JWT
48
+ }
49
+ ```
50
+
51
+ What an opened route means per kind:
52
+
53
+ - **TENANT token** — `S_EVERYONE` / `S_USER` / `S_VERIFIED` count as satisfied (the decorator is the
54
+ explicit permission for a machine); tenant roles resolve against the LOWEST hierarchy role; global
55
+ roles never match; `S_NO_ONE` refuses, and so does a route guarded ONLY by `S_SELF` / `S_CREATOR` —
56
+ those compare a person with a record, and a tenant token is no person. `X-Tenant-Id` is optional — it may only name the token's own
57
+ tenant. `@SkipTenantCheck()` does not unbind it.
58
+ - **USER token** — the ordinary checks run as its user. A tenant-restricted token is bound to that
59
+ tenant (a foreign header refuses, `@SkipTenantCheck()` does not unbind it); `maxTenantRole` caps the
60
+ membership role. Losing a membership removes the token's access at once — rights are read per
61
+ request, never copied into the token.
62
+
63
+ `request.user`:
64
+
65
+ - TENANT token: `{ id: <token id>, name, scopes, tenantId, roles: [], hasRole: () => false }`
66
+ - USER token: the user document (secrets never loaded, global roles removed) with `id` / `hasRole()`
67
+
68
+ `getApiTokenContext(user)` → `{ kind, tokenId, publicId, name, scopes, tenantId?, userId?, maxTenantRole?, assertion? }`
69
+ works for both. It is recognised by a module-private symbol, so a document carrying the same fields is
70
+ never mistaken for a token. `createdBy` / `updatedBy` written by a TENANT token hold the token id.
71
+
72
+ ## Using a token
73
+
74
+ ```http
75
+ GET /documents
76
+ Authorization: Bearer ltt_5f3c0a9e2b41d7c86e0f1a2b_9b1e… # or: x-api-key: ltt_…
77
+ X-Tenant-Id: <tenant> # optional for tenant tokens
78
+ ```
79
+
80
+ Token: `<prefix>_<publicId: 24 hex>_<secret: 64 hex>` (32 random bytes). Shown **once** at creation;
81
+ stored only as SHA-256 of the secret and compared in constant time.
82
+
83
+ | Situation | Status |
84
+ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
85
+ | Unknown, revoked, expired, tampered token or assertion; owner deleted (user tokens); two different credentials in `Authorization` and `x-api-key` | **401 on every route** (never silently anonymous) |
86
+ | Route not opened, scope missing, role too low, foreign tenant | 403 |
87
+ | Rate limit exceeded (per token, Redis-shared when configured) | 429 + `Retry-After` |
88
+
89
+ ## Signed assertions (embedding)
90
+
91
+ An embedding application must not ship the long-lived token to a browser. It keeps the token's
92
+ **signing key** (returned once at creation, 64 hex characters) on its server and mints a short-lived
93
+ assertion per page load. The assertion acts with the token's scopes and limits, carries an optional
94
+ `sub` / `claims` for the audit trail, and dies with the token (revocation takes effect immediately).
95
+
96
+ ```
97
+ payload = base64url( UTF-8 JSON { "claims"?: {...}, "exp": <unix seconds>, "nonce"?: "...", "sub"?: "...", "tid": "<publicId>" } )
98
+ signature = base64url( HMAC-SHA256( key = UTF-8 bytes of the signing key as issued,
99
+ data = ASCII bytes of the payload string ) )
100
+ assertion = "<prefix>s_" + payload + "." + signature # base64url WITHOUT padding
101
+ ```
102
+
103
+ `publicId` is the middle part of the token. `exp` may lie at most `maxAssertionLifetimeSeconds`
104
+ (default 900) in the future; 30 s of clock skew are tolerated on both edges. The server verifies the
105
+ signature over the payload string as received — key order and whitespace do not matter. `nonce` is
106
+ recorded, not enforced as single-use: an embedded page makes many requests with one assertion.
107
+
108
+ Node:
109
+
110
+ ```typescript
111
+ import { signApiTokenAssertion } from '@lenne.tech/nest-server';
112
+
113
+ const assertion = signApiTokenAssertion({ publicId, signingKey, expiresInSeconds: 300, subject: 'b7user' });
114
+ ```
115
+
116
+ C#:
117
+
118
+ ```csharp
119
+ using System.Security.Cryptography;
120
+ using System.Text;
121
+ using System.Text.Json;
122
+
123
+ static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
124
+
125
+ var json = JsonSerializer.Serialize(new Dictionary<string, object> {
126
+ ["exp"] = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds(),
127
+ ["sub"] = "b7user",
128
+ ["tid"] = publicId,
129
+ });
130
+ var payload = B64Url(Encoding.UTF8.GetBytes(json));
131
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey));
132
+ var assertion = $"ltts_{payload}.{B64Url(hmac.ComputeHash(Encoding.ASCII.GetBytes(payload)))}";
133
+ ```
134
+
135
+ PowerShell (5.1 and 7):
136
+
137
+ ```powershell
138
+ function ConvertTo-B64Url([byte[]]$b) { [Convert]::ToBase64String($b).TrimEnd('=').Replace('+','-').Replace('/','_') }
139
+ $json = @{ exp = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUnixTimeSeconds(); sub = 'b7user'; tid = $publicId } | ConvertTo-Json -Compress
140
+ $payload = ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($json))
141
+ $hmac = New-Object System.Security.Cryptography.HMACSHA256 (,[Text.Encoding]::UTF8.GetBytes($signingKey))
142
+ $assertion = "ltts_$payload." + (ConvertTo-B64Url ($hmac.ComputeHash([Text.Encoding]::ASCII.GetBytes($payload))))
143
+ ```
144
+
145
+ The signing key is stored AES-256-GCM encrypted (`apiTokens.encryptionKey`, fallback
146
+ `SECRETS_ENCRYPTION_KEY`). Rotating that key invalidates the signing keys — not the tokens — of all
147
+ existing tokens.
148
+
149
+ ## Management — `CoreApiTokenService`
150
+
151
+ Every method takes the acting user and checks the right itself; a project controller only forwards
152
+ `@CurrentUser()` / `@CurrentTenant()`. See `INTEGRATION-CHECKLIST.md` for a controller.
153
+
154
+ | Method | Who |
155
+ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
156
+ | `createUserToken(input, currentUser)` → `{ apiToken, token, signingKey }` | the user (for themselves) |
157
+ | `findUserTokens` / `updateUserToken` / `revokeUserToken` / `deleteUserToken` | the owner |
158
+ | `createTenantToken(tenantId, input, currentUser)` → `{ apiToken, token, signingKey }` | `manageRole` in the tenant, platform admin |
159
+ | `findTenantTokens` / `updateTenantToken` / `revokeTenantToken` / `deleteTenantToken` | same |
160
+ | `deleteAllForTenant(tenantId)` | system — call when deleting a tenant (also removes user tokens restricted to it) |
161
+ | `revokeAllForUser(userId)` / `deleteAllForUser(userId)` | system — account compromise / user deletion |
162
+
163
+ Inputs: `name` (required), `description`, `scopes` (subset of the vocabulary; a user token without
164
+ scopes gets the whole vocabulary), `expiresAt` (future, or `null`), for user tokens `tenantId` (an
165
+ active membership of the owner) and `maxTenantRole` (a hierarchy role). Any other field is stored as
166
+ given — that is how a project binds a token to its own data (after extending the model) — except the
167
+ protected ones (`kind`, `tenant`, `user`, `publicId`, hashes, audit fields, `revokedAt`, `lastUsedAt`).
168
+ Returned objects never contain the hash or the signing key.
169
+
170
+ ## Better-Auth
171
+
172
+ Runs alongside Better-Auth without changing it and also works without it (legacy mode):
173
+ `CoreBetterAuthMiddleware` leaves prefixed credentials alone, user tokens resolve the same `users`
174
+ document Better-Auth does, and `x-api-key` follows the header convention of Better-Auth's API-key
175
+ plugin. The official `@better-auth/api-key` plugin is deliberately not used: organisation-owned keys
176
+ there are authorised through Better-Auth's organization plugin rather than `CoreTenant`, its request
177
+ integration turns a key into a full session of its user on every route, it requires Better-Auth, and
178
+ it has no signed assertions.
179
+
180
+ ## Security notes for projects
181
+
182
+ - **Routes outside the Nest guards do not see `@ApiTokenScopes()`.** Anything mounted with `app.use()`
183
+ (an Express router, a static handler) runs after `CoreApiTokenMiddleware` but before no guard, so
184
+ `req.user` may be a token there. Treat `getApiTokenContext(req.user)` as "not a person" in such code.
185
+ The framework's own case — the MCP OAuth consent step — refuses token requests, because a consent
186
+ would mint an access token with the user's FULL rights.
187
+ - **A tenant token is not a user.** `@CurrentUser()` then has no `email`, no `verified`; code that
188
+ mails or notifies "the current user" must check `getApiTokenContext()` first. Field-level
189
+ `@Restricted(S_VERIFIED)` stays hidden from tenant tokens (fail-closed).
190
+ - **Open routes deliberately.** Never release routes that change credentials, e-mail, roles,
191
+ memberships or tokens. The management service refuses tokens anyway.
192
+ - **Password resets.** A reset that ends the user's sessions ends their user tokens too: the legacy
193
+ reset (`CoreUserService.resetPassword()`) always, the IAM reset while
194
+ `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on. With it off — and on a password
195
+ CHANGE — user tokens survive, like personal access tokens elsewhere; after a suspected compromise
196
+ call `revokeAllForUser(userId)`. Tenant tokens are never touched by a user's reset.
197
+ - **Deleting a tenant.** Call `deleteAllForTenant(tenantId)` — nothing does it for you, and until you
198
+ do, the tenant's tokens keep authenticating (the core has no tenant model to notice the deletion).
199
+ - **Banned users.** If you add Better-Auth's admin plugin, override `loadTokenUser()` so a banned
200
+ user's tokens are refused as well — the framework does not know that flag.
201
+ - **Brute force.** Failed attempts are not limited per IP (guessing 256 bits is not the risk); add an
202
+ IP limit in front of an exposed API if you want one.
203
+ - **Secret scanning:** the fixed shape `<prefix>_<24 hex>_<64 hex>` can be registered as a custom
204
+ pattern in your repository's secret scanning.
205
+
206
+ ## Limitations
207
+
208
+ - HTTP only: GraphQL over WebSocket (subscriptions) does not accept API tokens.
209
+ - Better-Auth's native `/iam/*` handlers do not recognise tokens — a token cannot sign in, change a
210
+ password or read a session.
211
+ - A 401/429 from the middleware on `/graphql` has the REST error shape, not the GraphQL one.
212
+ - `lastUsedAt` is accurate to one minute per replica.
@@ -0,0 +1,27 @@
1
+ // Import-free leaf on purpose: guards of three modules (auth, better-auth, tenant) read these values,
2
+ // and a leaf can never be mid-evaluation when one of them imports it (see .claude/rules/architecture.md
3
+ // → "DI Token Placement (SWC-Safe)").
4
+
5
+ /**
6
+ * Injection token / Mongoose model name for API tokens.
7
+ */
8
+ export const API_TOKEN_MODEL_TOKEN = 'ApiToken';
9
+
10
+ /**
11
+ * Metadata key for the `@ApiTokenScopes()` decorator.
12
+ */
13
+ export const API_TOKEN_SCOPES_KEY = 'apiTokenScopes';
14
+
15
+ /**
16
+ * Who an API token belongs to.
17
+ *
18
+ * - `USER`: owned by a user; acts with that user's CURRENT rights (never global roles such as ADMIN),
19
+ * optionally narrowed to scopes, one tenant and a maximum tenant role. Works with and without
20
+ * multi-tenancy.
21
+ * - `TENANT`: owned by a tenant, independent of any person; managed by the tenant's administrators,
22
+ * acts with the lowest tenant role and only inside its own tenant. Requires multi-tenancy.
23
+ */
24
+ export enum ApiTokenKind {
25
+ TENANT = 'tenant',
26
+ USER = 'user',
27
+ }
@@ -0,0 +1,29 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+
3
+ import { API_TOKEN_SCOPES_KEY } from './core-api-token.constants';
4
+
5
+ /**
6
+ * Method/class decorator that opens an endpoint to API tokens (`apiTokens` config).
7
+ *
8
+ * Tokens are DENIED on every route by default — user tokens and tenant tokens alike. A route accepts
9
+ * one only when it names the scopes that may call it; a token holding ANY one of them passes. A
10
+ * method-level declaration replaces the class-level one rather than adding to it, so a class can open
11
+ * itself for `'read'` while a single method narrows to `'export'`.
12
+ *
13
+ * Opening a route does not widen what a token may do there, it only stops the blanket refusal:
14
+ * - a USER token then acts as its user (without global roles), bounded by the token's own limits;
15
+ * - a TENANT token acts as a member of its tenant with the LOWEST role of the hierarchy, and
16
+ * `S_EVERYONE` / `S_USER` / `S_VERIFIED` count as satisfied for it.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * @ApiTokenScopes('upload')
21
+ * @Roles(RoleEnum.S_USER)
22
+ * @Post('documents')
23
+ * async upload(@CurrentUser() caller: any) {
24
+ * const token = getApiTokenContext(caller); // undefined for a session, set for a token
25
+ * }
26
+ * ```
27
+ */
28
+ export const ApiTokenScopes = (scope: string, ...moreScopes: string[]) =>
29
+ SetMetadata(API_TOKEN_SCOPES_KEY, [scope, ...moreScopes]);