@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 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, { foreignKey: 'parentRef' });
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, { foreignKey: 'parentRef' });
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
- - root entrypoint exports schema helpers and `isObjectId`; plugins and utils live under subpaths
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.37.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.37.0"
86
+ "@web-ts-toolkit/utils": "0.38.0"
69
87
  },
70
88
  "files": [
71
89
  "**/*",
@@ -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';
@@ -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 };