@web-ts-toolkit/moo 0.37.0 → 0.38.0
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.
- package/README.md +136 -1
- package/index.d.mts +1 -0
- package/index.d.ts +1 -0
- package/index.js +15 -0
- package/index.mjs +14 -0
- package/llms.txt +55 -2
- package/package.json +20 -2
- package/plugins/index.d.mts +1 -0
- package/plugins/index.d.ts +1 -0
- package/plugins/index.js +17 -2
- package/plugins/index.mjs +15 -1
- package/plugins/keycloak-user-sync.d.mts +88 -0
- package/plugins/keycloak-user-sync.d.ts +88 -0
- package/plugins/keycloak-user-sync.js +644 -0
- package/plugins/keycloak-user-sync.mjs +617 -0
- package/plugins/new-document.d.mts +13 -0
- package/plugins/new-document.d.ts +13 -0
- package/plugins/new-document.js +40 -0
- package/plugins/new-document.mjs +15 -0
package/README.md
CHANGED
|
@@ -11,13 +11,16 @@ pnpm add mongoose @web-ts-toolkit/moo
|
|
|
11
11
|
Peer dependencies:
|
|
12
12
|
|
|
13
13
|
- `mongoose >= 8`
|
|
14
|
+
- `@egose/keycloak-fluent ^0.7` only when using `@web-ts-toolkit/moo/plugins/keycloak-user-sync`
|
|
14
15
|
|
|
15
16
|
## Highlights
|
|
16
17
|
|
|
17
18
|
- partial-index helpers for nullable or empty-string fields
|
|
18
19
|
- strict `isObjectId(...)` guard
|
|
19
20
|
- model-function plugin
|
|
21
|
+
- new-document plugin
|
|
20
22
|
- cascade-delete plugin
|
|
23
|
+
- optional Keycloak user-sync plugin through a dedicated subpath
|
|
21
24
|
|
|
22
25
|
## Quick Start
|
|
23
26
|
|
|
@@ -37,6 +40,7 @@ Root entrypoint (`@web-ts-toolkit/moo`):
|
|
|
37
40
|
|
|
38
41
|
- schema helpers such as `uniqueNullableString(...)`
|
|
39
42
|
- `isObjectId(...)`
|
|
43
|
+
- document plugins except Keycloak user sync
|
|
40
44
|
|
|
41
45
|
Subpath entrypoints:
|
|
42
46
|
|
|
@@ -46,6 +50,10 @@ Subpath entrypoints:
|
|
|
46
50
|
- `@web-ts-toolkit/moo/plugins` — plugin entrypoint
|
|
47
51
|
- `@web-ts-toolkit/moo/plugins/cascade-delete` — cascade-delete plugin
|
|
48
52
|
- `@web-ts-toolkit/moo/plugins/model-function` — model-function plugin
|
|
53
|
+
- `@web-ts-toolkit/moo/plugins/new-document` — new-document plugin
|
|
54
|
+
- `@web-ts-toolkit/moo/plugins/keycloak-user-sync` — Keycloak user-sync plugin
|
|
55
|
+
|
|
56
|
+
The Keycloak plugin is intentionally not re-exported from the root or grouped `@web-ts-toolkit/moo/plugins` entrypoints. Non-Keycloak consumers only need `mongoose`; Keycloak consumers should install `@egose/keycloak-fluent` and import `keycloakUserSyncPlugin` from the direct subpath.
|
|
49
57
|
|
|
50
58
|
### Subpath import example
|
|
51
59
|
|
|
@@ -58,9 +66,136 @@ const userSchema = new Schema({
|
|
|
58
66
|
email: uniqueEmptiableString('email'),
|
|
59
67
|
});
|
|
60
68
|
|
|
61
|
-
userSchema.plugin(cascadeDeletePlugin, {
|
|
69
|
+
userSchema.plugin(cascadeDeletePlugin, {
|
|
70
|
+
model: 'Session',
|
|
71
|
+
localField: '_id',
|
|
72
|
+
foreignField: 'userId',
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## New Document Plugin
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { Schema } from 'mongoose';
|
|
80
|
+
import { newDocumentPlugin } from '@web-ts-toolkit/moo/plugins/new-document';
|
|
81
|
+
|
|
82
|
+
const userSchema = new Schema({
|
|
83
|
+
email: String,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
userSchema.plugin(newDocumentPlugin, {
|
|
87
|
+
async fn(user) {
|
|
88
|
+
await sendWelcomeEmail(user.email);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
62
91
|
```
|
|
63
92
|
|
|
93
|
+
The plugin runs `fn` after the first successful `save()` of a new document. Later saves of the same document do not run it.
|
|
94
|
+
|
|
95
|
+
## Keycloak User Sync
|
|
96
|
+
|
|
97
|
+
Install the optional Keycloak peer before using this entrypoint:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
pnpm add @egose/keycloak-fluent
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
105
|
+
import { Schema } from 'mongoose';
|
|
106
|
+
import { keycloakUserSyncPlugin } from '@web-ts-toolkit/moo/plugins/keycloak-user-sync';
|
|
107
|
+
|
|
108
|
+
const keycloak = new KeycloakAdminClientFluent({
|
|
109
|
+
baseUrl: process.env.KEYCLOAK_URL,
|
|
110
|
+
realmName: 'master',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await keycloak.simpleAuth({
|
|
114
|
+
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
|
115
|
+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const userSchema = new Schema({
|
|
119
|
+
providerId: String,
|
|
120
|
+
username: String,
|
|
121
|
+
email: String,
|
|
122
|
+
emailVerified: Boolean,
|
|
123
|
+
firstName: String,
|
|
124
|
+
lastName: String,
|
|
125
|
+
archived: Boolean,
|
|
126
|
+
roles: [String],
|
|
127
|
+
pendingPassword: String,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
userSchema.plugin(keycloakUserSyncPlugin, {
|
|
131
|
+
client: keycloak,
|
|
132
|
+
realm: 'application',
|
|
133
|
+
identifyBy: ['providerId', 'username', 'email'],
|
|
134
|
+
managedRoles: ['admin', 'editor', 'viewer'],
|
|
135
|
+
managedAttributes: ['tenantId', 'plan'],
|
|
136
|
+
paths: {
|
|
137
|
+
password: 'pendingPassword', // pragma: allowlist secret
|
|
138
|
+
},
|
|
139
|
+
syncFields: {
|
|
140
|
+
firstName: true,
|
|
141
|
+
lastName: true,
|
|
142
|
+
email: true,
|
|
143
|
+
roles: true,
|
|
144
|
+
attributes: true,
|
|
145
|
+
password: true,
|
|
146
|
+
},
|
|
147
|
+
passwordTemporary: true,
|
|
148
|
+
mapPassword(document) {
|
|
149
|
+
return document.get('pendingPassword') as string | undefined;
|
|
150
|
+
},
|
|
151
|
+
attributePaths: ['tenantId', 'subscription.plan'],
|
|
152
|
+
mapAttributes(document) {
|
|
153
|
+
return {
|
|
154
|
+
tenantId: document.get('tenantId'),
|
|
155
|
+
plan: document.get('subscription.plan'),
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
onError(error, context) {
|
|
159
|
+
reportKeycloakSyncError({ error, context });
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The plugin:
|
|
165
|
+
|
|
166
|
+
- creates or updates a Keycloak user after a document `save()`
|
|
167
|
+
- deletes the Keycloak user before a document `deleteOne()`
|
|
168
|
+
- stores the Keycloak user ID in `providerId` by default
|
|
169
|
+
- resets `emailVerified` and sends a verification email when an existing email changes and email-verification syncing is enabled
|
|
170
|
+
- creates desired realm roles by default and removes assigned roles only within `managedRoles`
|
|
171
|
+
- syncs Keycloak user attributes from an `attributes` path or a custom `mapAttributes` function
|
|
172
|
+
- can update the Keycloak password when `syncFields.password` is explicitly enabled
|
|
173
|
+
- reads `archived` as the inverse of Keycloak `enabled`, falling back to an `enabled` field
|
|
174
|
+
|
|
175
|
+
Configuration is validated when `schema.plugin(...)` runs. `realm`, configured paths, managed role/attribute names, and attribute trigger paths are trimmed and must be non-empty. `identifyBy` accepts one identity or a non-empty ordered list containing only `providerId`, `username`, and `email`. Built-in synced field paths must exist in the Mongoose schema; mapper-driven `attributePaths` can name dynamic fields that the mapper reads. Register the plugin only once per schema.
|
|
176
|
+
|
|
177
|
+
The plugin snapshots its options at registration, so later mutation of the caller's options object does not change sync behavior. `providerId` is server-controlled after persistence: applications may set it while creating a new document to link an existing Keycloak user, but changing it later is rejected before any remote call so ordinary document updates cannot redirect synchronization to another Keycloak account.
|
|
178
|
+
|
|
179
|
+
When the realm allows duplicate emails, the plugin always prioritizes configured `providerId` and `username` identities before email. An email lookup proceeds only when it has exactly one match; multiple matches are reported as an error, and no user is changed or deleted.
|
|
180
|
+
|
|
181
|
+
Use `paths` to map different Mongoose path names. Use `syncFields` to disable profile fields, role syncing, or attribute syncing. Use `mapRoles` to translate application roles. Role sync is additive-only by default: desired local roles are assigned, but unrelated existing Keycloak realm roles are preserved. Set `managedRoles` to the exact role names this plugin owns; only those roles may be removed when omitted from an explicit local roles array. An absent or non-array roles value is treated as no role-sync intent. An empty array intentionally removes currently assigned managed roles while preserving unmanaged roles. `ensureRoles` defaults to `true`, so desired missing roles are created before assignment; set it to `false` if typos or insufficient administrative privileges should fail instead of creating roles. `maxRolesPerSync` defaults to `100` and rejects larger desired role arrays before role lookup or mapping requests, bounding caller-controlled remote work.
|
|
182
|
+
|
|
183
|
+
Email comparison is case-insensitive. Linking a new local document to an existing Keycloak user with the same email preserves the remote `emailVerified` value and does not send a verification email. A persisted local email change, or a detected remote email drift corrected back to the local value, resets `emailVerified` to `false` and sends VERIFY_EMAIL by default. Set `sendVerificationEmailOnChange: false` to skip the email action. Set `syncFields.emailVerified: false` to disable all email-verification writes, including forced revocation and VERIFY_EMAIL sends.
|
|
184
|
+
|
|
185
|
+
For existing Keycloak users, owned string profile fields (`username`, `email`, `firstName`, and `lastName`) are updated when their local value is a non-empty string. Set a synced field to `null`, an empty string, or a whitespace-only string to clear the remote value. Leave it `undefined`, set `syncFields.<field>: false`, or omit the schema path from change detection to preserve unmanaged remote profile data. New-user creation omits clearing values because there is no stale remote value to remove.
|
|
186
|
+
|
|
187
|
+
Attributes are normalized to Keycloak string arrays. Use `mapAttributes` for dynamic key/value mappings and `attributePaths` to list the Mongoose fields that should trigger an attribute resync. Existing unmanaged Keycloak attributes are preserved, including when a user is resolved by email. Set `managedAttributes` to the keys this plugin owns; managed keys are replaced by the mapper result and removed when omitted, returned as `null`/`undefined`, returned as an empty array, or mapped to unsupported object values. Attribute keys named `__proto__`, `prototype`, or `constructor` are rejected.
|
|
188
|
+
|
|
189
|
+
Password sync is disabled by default to avoid sending stored password hashes to Keycloak. Enable it only when the mapped value is the plaintext password intended for Keycloak, such as a short-lived `pendingPassword` field. Passwords are never included in user-create or profile-update payloads; both newly created users and existing users are updated through Keycloak's reset-password endpoint with the configured `passwordTemporary` value. The plugin stores a newly resolved Keycloak user ID before optional password, role, and verification-email work so a later retry can converge on the same remote user instead of creating a duplicate. The application owns the lifecycle of plaintext password fields: keep them short-lived and prevent them from being persisted, logged, traced, or sent to error reporters beyond the intended Keycloak operation.
|
|
190
|
+
|
|
191
|
+
Errors are logged to `console.error` and rethrown by default. The default logger context is redacted to safe metadata such as `operation` and `localDocumentId`; it never includes the Mongoose document, profile payload, email address, or password. `onError` receives the same safe context by default. Set `includeDocumentInErrorContext: true` only for private handlers that are allowed to receive the full Mongoose document, which can contain plaintext passwords, email addresses, attributes, and arbitrary application fields. Logger and `onError` failures do not replace the original Keycloak sync error; with `throwOnError: false`, those observer failures are best-effort and are swallowed.
|
|
192
|
+
|
|
193
|
+
Only document `save()` and document `deleteOne()` are intercepted. Query operations such as `updateOne()`, `findOneAndUpdate()`, and query `deleteOne()` bypass the plugin. The selected delivery contract is direct, non-atomic hooks: Keycloak work is not part of the MongoDB commit, and the plugin rejects documents saved or deleted with a Mongoose session or transaction. Applications that need transactional delivery should write their own outbox intent in the MongoDB transaction and process it after commit.
|
|
194
|
+
|
|
195
|
+
For saves, a Keycloak failure after MongoDB persistence cannot roll back the local write. With `throwOnError: true`, the original error is rethrown after the post-save hook; with `throwOnError: false`, logging and `onError` make the failure observable and the application must retry by changing/saving the document or by running its own repair job. For deletes, remote deletion runs before local deletion. If the remote delete fails, the local document deletion is blocked even when `throwOnError: false`, leaving the same provider ID available for an idempotent retry.
|
|
196
|
+
|
|
197
|
+
Remote work is field-specific after identity resolution. A single owned profile or attribute change resolves the user and performs one update, without password reset or role reconciliation. A new user with no role-sync intent skips role mapping calls. Role reconciliation is sequential and deterministic; it performs one ensure/get pair per desired owned role plus one mapping list and optional add/remove calls. Realm metadata is fetched for each sync instead of cached, so duplicate-email policy changes are observed without an invalidation API.
|
|
198
|
+
|
|
64
199
|
## Documentation
|
|
65
200
|
|
|
66
201
|
Full package documentation lives in `website/docs/packages/moo.md`.
|
package/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './plugins/model-function.mjs';
|
|
2
2
|
export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './plugins/cascade-delete.mjs';
|
|
3
|
+
export { NewDocumentPluginOptions, newDocumentPlugin } from './plugins/new-document.mjs';
|
|
3
4
|
export { uniqueEmptiableString, uniqueNullableString } from './schema.mjs';
|
|
4
5
|
export { isObjectId } from './is.mjs';
|
|
5
6
|
import 'mongoose';
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './plugins/model-function.js';
|
|
2
2
|
export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './plugins/cascade-delete.js';
|
|
3
|
+
export { NewDocumentPluginOptions, newDocumentPlugin } from './plugins/new-document.js';
|
|
3
4
|
export { uniqueEmptiableString, uniqueNullableString } from './schema.js';
|
|
4
5
|
export { isObjectId } from './is.js';
|
|
5
6
|
import 'mongoose';
|
package/index.js
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
cascadeDeletePlugin: () => cascadeDeletePlugin,
|
|
34
34
|
isObjectId: () => isObjectId,
|
|
35
35
|
modelFunctionPlugin: () => modelFunctionPlugin,
|
|
36
|
+
newDocumentPlugin: () => newDocumentPlugin,
|
|
36
37
|
uniqueEmptiableString: () => uniqueEmptiableString,
|
|
37
38
|
uniqueNullableString: () => uniqueNullableString
|
|
38
39
|
});
|
|
@@ -199,6 +200,19 @@ function cascadeDeletePlugin(schema, options) {
|
|
|
199
200
|
});
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
// src/plugins/new-document.ts
|
|
204
|
+
var stateKey = "newDocumentPlugin";
|
|
205
|
+
function newDocumentPlugin(schema, options) {
|
|
206
|
+
schema.pre("save", function newDocumentPluginPreSave() {
|
|
207
|
+
this.$locals[stateKey] = { wasNew: this.isNew };
|
|
208
|
+
});
|
|
209
|
+
schema.post("save", async function newDocumentPluginPostSave(document) {
|
|
210
|
+
const state = this.$locals[stateKey];
|
|
211
|
+
if (!state?.wasNew) return;
|
|
212
|
+
await options.fn.call(this, document);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
202
216
|
// src/schema.ts
|
|
203
217
|
var uniqueNullableString = (field, options = {}) => ({
|
|
204
218
|
type: String,
|
|
@@ -239,6 +253,7 @@ function isObjectId(value) {
|
|
|
239
253
|
cascadeDeletePlugin,
|
|
240
254
|
isObjectId,
|
|
241
255
|
modelFunctionPlugin,
|
|
256
|
+
newDocumentPlugin,
|
|
242
257
|
uniqueEmptiableString,
|
|
243
258
|
uniqueNullableString
|
|
244
259
|
});
|
package/index.mjs
CHANGED
|
@@ -159,6 +159,19 @@ function cascadeDeletePlugin(schema, options) {
|
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// src/plugins/new-document.ts
|
|
163
|
+
var stateKey = "newDocumentPlugin";
|
|
164
|
+
function newDocumentPlugin(schema, options) {
|
|
165
|
+
schema.pre("save", function newDocumentPluginPreSave() {
|
|
166
|
+
this.$locals[stateKey] = { wasNew: this.isNew };
|
|
167
|
+
});
|
|
168
|
+
schema.post("save", async function newDocumentPluginPostSave(document) {
|
|
169
|
+
const state = this.$locals[stateKey];
|
|
170
|
+
if (!state?.wasNew) return;
|
|
171
|
+
await options.fn.call(this, document);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
162
175
|
// src/schema.ts
|
|
163
176
|
var uniqueNullableString = (field, options = {}) => ({
|
|
164
177
|
type: String,
|
|
@@ -198,6 +211,7 @@ export {
|
|
|
198
211
|
cascadeDeletePlugin,
|
|
199
212
|
isObjectId,
|
|
200
213
|
modelFunctionPlugin,
|
|
214
|
+
newDocumentPlugin,
|
|
201
215
|
uniqueEmptiableString,
|
|
202
216
|
uniqueNullableString
|
|
203
217
|
};
|
package/llms.txt
CHANGED
|
@@ -29,7 +29,11 @@ Cascade-delete plugin:
|
|
|
29
29
|
```ts
|
|
30
30
|
import { cascadeDeletePlugin } from '@web-ts-toolkit/moo/plugins/cascade-delete';
|
|
31
31
|
|
|
32
|
-
userSchema.plugin(cascadeDeletePlugin, {
|
|
32
|
+
userSchema.plugin(cascadeDeletePlugin, {
|
|
33
|
+
model: 'Session',
|
|
34
|
+
localField: '_id',
|
|
35
|
+
foreignField: 'userId',
|
|
36
|
+
});
|
|
33
37
|
```
|
|
34
38
|
|
|
35
39
|
Model-function plugin:
|
|
@@ -45,12 +49,59 @@ userSchema.plugin(modelFunctionPlugin, {
|
|
|
45
49
|
// static + instance methods `fullName` and `fullNameById` are now available on the model
|
|
46
50
|
```
|
|
47
51
|
|
|
52
|
+
New-document plugin:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { newDocumentPlugin } from '@web-ts-toolkit/moo/plugins/new-document';
|
|
56
|
+
|
|
57
|
+
userSchema.plugin(newDocumentPlugin, {
|
|
58
|
+
fn: async (doc) => {
|
|
59
|
+
await sendWelcomeEmail(doc.get('email'));
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Keycloak user-sync plugin:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { keycloakUserSyncPlugin } from '@web-ts-toolkit/moo/plugins/keycloak-user-sync';
|
|
68
|
+
|
|
69
|
+
userSchema.plugin(keycloakUserSyncPlugin, {
|
|
70
|
+
client: authenticatedKeycloakFluentClient,
|
|
71
|
+
realm: 'application',
|
|
72
|
+
identifyBy: ['providerId', 'username', 'email'],
|
|
73
|
+
managedRoles: ['admin', 'editor', 'viewer'],
|
|
74
|
+
managedAttributes: ['tenantId', 'plan'],
|
|
75
|
+
paths: { password: 'pendingPassword' }, // pragma: allowlist secret
|
|
76
|
+
syncFields: { email: true, firstName: true, lastName: true, roles: true, attributes: true, password: true },
|
|
77
|
+
passwordTemporary: true,
|
|
78
|
+
mapPassword: (doc) => doc.get('pendingPassword') as string | undefined,
|
|
79
|
+
attributePaths: ['tenantId', 'subscription.plan'],
|
|
80
|
+
mapAttributes: (doc) => ({
|
|
81
|
+
tenantId: doc.get('tenantId'),
|
|
82
|
+
plan: doc.get('subscription.plan'),
|
|
83
|
+
}),
|
|
84
|
+
onError: reportSyncError,
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
48
88
|
## Gotchas
|
|
49
89
|
|
|
50
90
|
- peer dependency: `mongoose >= 8`
|
|
51
|
-
-
|
|
91
|
+
- optional peer dependency: `@egose/keycloak-fluent ^0.7` only for `@web-ts-toolkit/moo/plugins/keycloak-user-sync`
|
|
92
|
+
- root entrypoint exports schema helpers, `isObjectId`, and non-Keycloak plugins; dedicated plugin subpaths are also available
|
|
93
|
+
- Keycloak user sync is not re-exported from root or `@web-ts-toolkit/moo/plugins`; import it from the direct subpath
|
|
52
94
|
- `isObjectId(...)` is a strict guard: it rejects strings that `ObjectId.isValid` accepts but that don't round-trip back to the same string
|
|
53
95
|
- `uniqueNullableString`/`uniqueEmptiableString` return partial-index field definitions, not full schemas
|
|
96
|
+
- new-document plugin runs its callback only after the first successful `save()` of a newly inserted document
|
|
97
|
+
- Keycloak sync covers document `save()` and document `deleteOne()`, not query updates/deletes
|
|
98
|
+
- Keycloak sync errors are logged and rethrown by default; MongoDB saves have already committed when post-save sync runs
|
|
99
|
+
- duplicate-email realms prioritize providerId/username; email lookup is rejected when more than one exact match exists
|
|
100
|
+
- Keycloak attributes sync from the `attributes` path or `mapAttributes`; values become string arrays
|
|
101
|
+
- use `managedAttributes` for keys the plugin may remove; unmanaged remote attributes are preserved
|
|
102
|
+
- use `attributePaths` when `mapAttributes` reads dynamic paths outside the default `attributes` path
|
|
103
|
+
- password sync is opt-in with `syncFields.password: true`; map a plaintext pending password, not a stored hash
|
|
104
|
+
- password updates use Keycloak reset-password; `passwordTemporary` defaults to false
|
|
54
105
|
|
|
55
106
|
## Subpaths
|
|
56
107
|
|
|
@@ -60,6 +111,8 @@ userSchema.plugin(modelFunctionPlugin, {
|
|
|
60
111
|
- `@web-ts-toolkit/moo/plugins` — re-exports all plugins
|
|
61
112
|
- `@web-ts-toolkit/moo/plugins/cascade-delete` — `cascadeDeletePlugin`
|
|
62
113
|
- `@web-ts-toolkit/moo/plugins/model-function` — `modelFunctionPlugin`
|
|
114
|
+
- `@web-ts-toolkit/moo/plugins/new-document` — `newDocumentPlugin`
|
|
115
|
+
- `@web-ts-toolkit/moo/plugins/keycloak-user-sync` — `keycloakUserSyncPlugin`
|
|
63
116
|
|
|
64
117
|
## Pointers
|
|
65
118
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/moo",
|
|
3
3
|
"description": "Mongoose helpers for schema fields, ObjectId checks, and document plugins",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/moo",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.38.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"mongoose",
|
|
@@ -56,16 +56,34 @@
|
|
|
56
56
|
"import": "./plugins/model-function.mjs",
|
|
57
57
|
"require": "./plugins/model-function.js",
|
|
58
58
|
"default": "./plugins/model-function.js"
|
|
59
|
+
},
|
|
60
|
+
"./plugins/new-document": {
|
|
61
|
+
"types": "./plugins/new-document.d.ts",
|
|
62
|
+
"import": "./plugins/new-document.mjs",
|
|
63
|
+
"require": "./plugins/new-document.js",
|
|
64
|
+
"default": "./plugins/new-document.js"
|
|
65
|
+
},
|
|
66
|
+
"./plugins/keycloak-user-sync": {
|
|
67
|
+
"types": "./plugins/keycloak-user-sync.d.ts",
|
|
68
|
+
"import": "./plugins/keycloak-user-sync.mjs",
|
|
69
|
+
"require": "./plugins/keycloak-user-sync.js",
|
|
70
|
+
"default": "./plugins/keycloak-user-sync.js"
|
|
59
71
|
}
|
|
60
72
|
},
|
|
61
73
|
"engines": {
|
|
62
74
|
"node": ">=22"
|
|
63
75
|
},
|
|
64
76
|
"peerDependencies": {
|
|
77
|
+
"@egose/keycloak-fluent": "^0.7.0",
|
|
65
78
|
"mongoose": ">=8.0.0"
|
|
66
79
|
},
|
|
80
|
+
"peerDependenciesMeta": {
|
|
81
|
+
"@egose/keycloak-fluent": {
|
|
82
|
+
"optional": true
|
|
83
|
+
}
|
|
84
|
+
},
|
|
67
85
|
"dependencies": {
|
|
68
|
-
"@web-ts-toolkit/utils": "0.
|
|
86
|
+
"@web-ts-toolkit/utils": "0.38.0"
|
|
69
87
|
},
|
|
70
88
|
"files": [
|
|
71
89
|
"**/*",
|
package/plugins/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './model-function.mjs';
|
|
2
2
|
export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './cascade-delete.mjs';
|
|
3
|
+
export { NewDocumentPluginOptions, newDocumentPlugin } from './new-document.mjs';
|
|
3
4
|
import 'mongoose';
|
package/plugins/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ModelDocument, ModelFunctionInstanceMethods, ModelFunctionPluginOptions, ModelFunctionStaticMethods, modelFunctionPlugin } from './model-function.js';
|
|
2
2
|
export { CascadeDeleteDependencyMap, CascadeDeleteDocumentMethods, CascadeDeleteModelStatics, CascadeDeletePluginOptions, FilterResolver, QueryFilter, cascadeDeletePlugin } from './cascade-delete.js';
|
|
3
|
+
export { NewDocumentPluginOptions, newDocumentPlugin } from './new-document.js';
|
|
3
4
|
import 'mongoose';
|
package/plugins/index.js
CHANGED
|
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var plugins_exports = {};
|
|
32
32
|
__export(plugins_exports, {
|
|
33
33
|
cascadeDeletePlugin: () => cascadeDeletePlugin,
|
|
34
|
-
modelFunctionPlugin: () => modelFunctionPlugin
|
|
34
|
+
modelFunctionPlugin: () => modelFunctionPlugin,
|
|
35
|
+
newDocumentPlugin: () => newDocumentPlugin
|
|
35
36
|
});
|
|
36
37
|
module.exports = __toCommonJS(plugins_exports);
|
|
37
38
|
|
|
@@ -195,8 +196,22 @@ function cascadeDeletePlugin(schema, options) {
|
|
|
195
196
|
return mergeResults(previousResults, model, currentResults);
|
|
196
197
|
});
|
|
197
198
|
}
|
|
199
|
+
|
|
200
|
+
// src/plugins/new-document.ts
|
|
201
|
+
var stateKey = "newDocumentPlugin";
|
|
202
|
+
function newDocumentPlugin(schema, options) {
|
|
203
|
+
schema.pre("save", function newDocumentPluginPreSave() {
|
|
204
|
+
this.$locals[stateKey] = { wasNew: this.isNew };
|
|
205
|
+
});
|
|
206
|
+
schema.post("save", async function newDocumentPluginPostSave(document) {
|
|
207
|
+
const state = this.$locals[stateKey];
|
|
208
|
+
if (!state?.wasNew) return;
|
|
209
|
+
await options.fn.call(this, document);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
198
212
|
// Annotate the CommonJS export names for ESM import in node:
|
|
199
213
|
0 && (module.exports = {
|
|
200
214
|
cascadeDeletePlugin,
|
|
201
|
-
modelFunctionPlugin
|
|
215
|
+
modelFunctionPlugin,
|
|
216
|
+
newDocumentPlugin
|
|
202
217
|
});
|
package/plugins/index.mjs
CHANGED
|
@@ -158,7 +158,21 @@ function cascadeDeletePlugin(schema, options) {
|
|
|
158
158
|
return mergeResults(previousResults, model, currentResults);
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
// src/plugins/new-document.ts
|
|
163
|
+
var stateKey = "newDocumentPlugin";
|
|
164
|
+
function newDocumentPlugin(schema, options) {
|
|
165
|
+
schema.pre("save", function newDocumentPluginPreSave() {
|
|
166
|
+
this.$locals[stateKey] = { wasNew: this.isNew };
|
|
167
|
+
});
|
|
168
|
+
schema.post("save", async function newDocumentPluginPostSave(document) {
|
|
169
|
+
const state = this.$locals[stateKey];
|
|
170
|
+
if (!state?.wasNew) return;
|
|
171
|
+
await options.fn.call(this, document);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
161
174
|
export {
|
|
162
175
|
cascadeDeletePlugin,
|
|
163
|
-
modelFunctionPlugin
|
|
176
|
+
modelFunctionPlugin,
|
|
177
|
+
newDocumentPlugin
|
|
164
178
|
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
2
|
+
import { Schema } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
type KeycloakUserIdentityField = 'providerId' | 'username' | 'email';
|
|
5
|
+
type KeycloakUserSyncField = 'username' | 'email' | 'emailVerified' | 'firstName' | 'lastName' | 'enabled' | 'roles' | 'attributes' | 'password';
|
|
6
|
+
interface KeycloakUserSyncPaths {
|
|
7
|
+
providerId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
email: string;
|
|
10
|
+
emailVerified: string;
|
|
11
|
+
firstName: string;
|
|
12
|
+
lastName: string;
|
|
13
|
+
enabled: string;
|
|
14
|
+
archived: string;
|
|
15
|
+
roles: string;
|
|
16
|
+
attributes: string;
|
|
17
|
+
password: string;
|
|
18
|
+
}
|
|
19
|
+
interface KeycloakUserSyncErrorContext {
|
|
20
|
+
operation: 'save' | 'delete';
|
|
21
|
+
localDocumentId?: string;
|
|
22
|
+
}
|
|
23
|
+
interface KeycloakUserSyncSensitiveErrorContext extends KeycloakUserSyncErrorContext {
|
|
24
|
+
document: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface KeycloakUserSyncLogger {
|
|
27
|
+
error(message: string, context: KeycloakUserSyncErrorContext & {
|
|
28
|
+
error: unknown;
|
|
29
|
+
}): void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
interface KeycloakUserSyncDocument {
|
|
32
|
+
get(path: string): unknown;
|
|
33
|
+
}
|
|
34
|
+
interface KeycloakUserSyncPluginOptions {
|
|
35
|
+
/** An authenticated client. Authentication and token refresh remain the application's responsibility. */
|
|
36
|
+
client: KeycloakAdminClientFluent;
|
|
37
|
+
realm: string;
|
|
38
|
+
/**
|
|
39
|
+
* Identity fields to use. Must be non-empty. Defaults to providerId, username, then email.
|
|
40
|
+
* `providerId` is treated as server-controlled after persistence and cannot be reassigned by ordinary saves.
|
|
41
|
+
*/
|
|
42
|
+
identifyBy?: KeycloakUserIdentityField | readonly KeycloakUserIdentityField[];
|
|
43
|
+
/** Override the Mongoose path used for each application-user property. Built-in synced fields must exist in the schema. */
|
|
44
|
+
paths?: Partial<KeycloakUserSyncPaths>;
|
|
45
|
+
/** Set a field to false to exclude it from change detection and Keycloak updates. */
|
|
46
|
+
syncFields?: Partial<Record<KeycloakUserSyncField, boolean>>;
|
|
47
|
+
/** Convert the configured roles path into Keycloak realm-role names. Role removals are limited by `managedRoles`. */
|
|
48
|
+
mapRoles?: (roles: unknown, document: KeycloakUserSyncDocument) => readonly string[];
|
|
49
|
+
/** Convert document state into Keycloak user attributes. Values are normalized to string arrays and unsafe keys are rejected. */
|
|
50
|
+
mapAttributes?: (document: KeycloakUserSyncDocument) => Record<string, unknown> | null | undefined;
|
|
51
|
+
/** Convert document state into a plaintext Keycloak password. Only used when syncFields.password is true. */
|
|
52
|
+
mapPassword?: (document: KeycloakUserSyncDocument) => string | null | undefined;
|
|
53
|
+
/** Mongoose paths that should trigger attribute syncing when mapAttributes reads dynamic fields. */
|
|
54
|
+
attributePaths?: readonly string[];
|
|
55
|
+
/** Attribute keys owned by this plugin. Only managed keys are removed when missing from the mapper result. */
|
|
56
|
+
managedAttributes?: readonly string[];
|
|
57
|
+
/** Role names this plugin owns. Role removal is limited to this set; without it, role sync is additive-only. */
|
|
58
|
+
managedRoles?: readonly string[];
|
|
59
|
+
/** Create missing desired realm roles before assigning them. Defaults to true; disable to fail on unknown roles. */
|
|
60
|
+
ensureRoles?: boolean;
|
|
61
|
+
/** Maximum desired role names accepted for one sync operation. Defaults to 100. */
|
|
62
|
+
maxRolesPerSync?: number;
|
|
63
|
+
/** Save a newly resolved Keycloak ID to the providerId path. Defaults to true. The providerId path is immutable after persistence. */
|
|
64
|
+
persistProviderId?: boolean;
|
|
65
|
+
/** Send VERIFY_EMAIL after changing an existing user's email when emailVerified syncing is enabled. Defaults to true. */
|
|
66
|
+
sendVerificationEmailOnChange?: boolean;
|
|
67
|
+
/** Mark reset-password credentials for created and existing users as temporary. Defaults to false. */
|
|
68
|
+
passwordTemporary?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Called after a sync error. By default the context contains only safe metadata.
|
|
71
|
+
* Enable includeDocumentInErrorContext only for private handlers that can receive sensitive document state.
|
|
72
|
+
*/
|
|
73
|
+
onError?: (error: unknown, context: KeycloakUserSyncErrorContext | KeycloakUserSyncSensitiveErrorContext) => void | Promise<void>;
|
|
74
|
+
/** Include the full Mongoose document in onError context. The document may contain plaintext passwords and PII. */
|
|
75
|
+
includeDocumentInErrorContext?: boolean;
|
|
76
|
+
/** Set to false to disable logging or provide a structured logger. Defaults to console. */
|
|
77
|
+
logger?: KeycloakUserSyncLogger | false;
|
|
78
|
+
/** Set to false to let document saves succeed after a Keycloak error. Deletes still block on remote delete failure. Defaults to true. */
|
|
79
|
+
throwOnError?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Synchronizes document saves and document `deleteOne()` calls with a Keycloak user.
|
|
83
|
+
* Query updates and query deletes are intentionally not intercepted because they do not provide a document identity.
|
|
84
|
+
* Direct hooks are non-atomic with MongoDB and reject Mongoose sessions/transactions; use an application outbox for transactional delivery.
|
|
85
|
+
*/
|
|
86
|
+
declare function keycloakUserSyncPlugin(schema: Schema, rawOptions: KeycloakUserSyncPluginOptions): void;
|
|
87
|
+
|
|
88
|
+
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, keycloakUserSyncPlugin };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
2
|
+
import { Schema } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
type KeycloakUserIdentityField = 'providerId' | 'username' | 'email';
|
|
5
|
+
type KeycloakUserSyncField = 'username' | 'email' | 'emailVerified' | 'firstName' | 'lastName' | 'enabled' | 'roles' | 'attributes' | 'password';
|
|
6
|
+
interface KeycloakUserSyncPaths {
|
|
7
|
+
providerId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
email: string;
|
|
10
|
+
emailVerified: string;
|
|
11
|
+
firstName: string;
|
|
12
|
+
lastName: string;
|
|
13
|
+
enabled: string;
|
|
14
|
+
archived: string;
|
|
15
|
+
roles: string;
|
|
16
|
+
attributes: string;
|
|
17
|
+
password: string;
|
|
18
|
+
}
|
|
19
|
+
interface KeycloakUserSyncErrorContext {
|
|
20
|
+
operation: 'save' | 'delete';
|
|
21
|
+
localDocumentId?: string;
|
|
22
|
+
}
|
|
23
|
+
interface KeycloakUserSyncSensitiveErrorContext extends KeycloakUserSyncErrorContext {
|
|
24
|
+
document: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface KeycloakUserSyncLogger {
|
|
27
|
+
error(message: string, context: KeycloakUserSyncErrorContext & {
|
|
28
|
+
error: unknown;
|
|
29
|
+
}): void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
interface KeycloakUserSyncDocument {
|
|
32
|
+
get(path: string): unknown;
|
|
33
|
+
}
|
|
34
|
+
interface KeycloakUserSyncPluginOptions {
|
|
35
|
+
/** An authenticated client. Authentication and token refresh remain the application's responsibility. */
|
|
36
|
+
client: KeycloakAdminClientFluent;
|
|
37
|
+
realm: string;
|
|
38
|
+
/**
|
|
39
|
+
* Identity fields to use. Must be non-empty. Defaults to providerId, username, then email.
|
|
40
|
+
* `providerId` is treated as server-controlled after persistence and cannot be reassigned by ordinary saves.
|
|
41
|
+
*/
|
|
42
|
+
identifyBy?: KeycloakUserIdentityField | readonly KeycloakUserIdentityField[];
|
|
43
|
+
/** Override the Mongoose path used for each application-user property. Built-in synced fields must exist in the schema. */
|
|
44
|
+
paths?: Partial<KeycloakUserSyncPaths>;
|
|
45
|
+
/** Set a field to false to exclude it from change detection and Keycloak updates. */
|
|
46
|
+
syncFields?: Partial<Record<KeycloakUserSyncField, boolean>>;
|
|
47
|
+
/** Convert the configured roles path into Keycloak realm-role names. Role removals are limited by `managedRoles`. */
|
|
48
|
+
mapRoles?: (roles: unknown, document: KeycloakUserSyncDocument) => readonly string[];
|
|
49
|
+
/** Convert document state into Keycloak user attributes. Values are normalized to string arrays and unsafe keys are rejected. */
|
|
50
|
+
mapAttributes?: (document: KeycloakUserSyncDocument) => Record<string, unknown> | null | undefined;
|
|
51
|
+
/** Convert document state into a plaintext Keycloak password. Only used when syncFields.password is true. */
|
|
52
|
+
mapPassword?: (document: KeycloakUserSyncDocument) => string | null | undefined;
|
|
53
|
+
/** Mongoose paths that should trigger attribute syncing when mapAttributes reads dynamic fields. */
|
|
54
|
+
attributePaths?: readonly string[];
|
|
55
|
+
/** Attribute keys owned by this plugin. Only managed keys are removed when missing from the mapper result. */
|
|
56
|
+
managedAttributes?: readonly string[];
|
|
57
|
+
/** Role names this plugin owns. Role removal is limited to this set; without it, role sync is additive-only. */
|
|
58
|
+
managedRoles?: readonly string[];
|
|
59
|
+
/** Create missing desired realm roles before assigning them. Defaults to true; disable to fail on unknown roles. */
|
|
60
|
+
ensureRoles?: boolean;
|
|
61
|
+
/** Maximum desired role names accepted for one sync operation. Defaults to 100. */
|
|
62
|
+
maxRolesPerSync?: number;
|
|
63
|
+
/** Save a newly resolved Keycloak ID to the providerId path. Defaults to true. The providerId path is immutable after persistence. */
|
|
64
|
+
persistProviderId?: boolean;
|
|
65
|
+
/** Send VERIFY_EMAIL after changing an existing user's email when emailVerified syncing is enabled. Defaults to true. */
|
|
66
|
+
sendVerificationEmailOnChange?: boolean;
|
|
67
|
+
/** Mark reset-password credentials for created and existing users as temporary. Defaults to false. */
|
|
68
|
+
passwordTemporary?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Called after a sync error. By default the context contains only safe metadata.
|
|
71
|
+
* Enable includeDocumentInErrorContext only for private handlers that can receive sensitive document state.
|
|
72
|
+
*/
|
|
73
|
+
onError?: (error: unknown, context: KeycloakUserSyncErrorContext | KeycloakUserSyncSensitiveErrorContext) => void | Promise<void>;
|
|
74
|
+
/** Include the full Mongoose document in onError context. The document may contain plaintext passwords and PII. */
|
|
75
|
+
includeDocumentInErrorContext?: boolean;
|
|
76
|
+
/** Set to false to disable logging or provide a structured logger. Defaults to console. */
|
|
77
|
+
logger?: KeycloakUserSyncLogger | false;
|
|
78
|
+
/** Set to false to let document saves succeed after a Keycloak error. Deletes still block on remote delete failure. Defaults to true. */
|
|
79
|
+
throwOnError?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Synchronizes document saves and document `deleteOne()` calls with a Keycloak user.
|
|
83
|
+
* Query updates and query deletes are intentionally not intercepted because they do not provide a document identity.
|
|
84
|
+
* Direct hooks are non-atomic with MongoDB and reject Mongoose sessions/transactions; use an application outbox for transactional delivery.
|
|
85
|
+
*/
|
|
86
|
+
declare function keycloakUserSyncPlugin(schema: Schema, rawOptions: KeycloakUserSyncPluginOptions): void;
|
|
87
|
+
|
|
88
|
+
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, keycloakUserSyncPlugin };
|