ldap-rest 1.3.0 → 2.0.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.
Files changed (71) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/plugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/options.js +1 -1
  5. package/dist/config/schema.js +1 -1
  6. package/dist/lib/authz/base.js +1 -1
  7. package/dist/lib/errors.js +1 -1
  8. package/dist/lib/ldap/callerView.js +1 -0
  9. package/dist/lib/ldap/pool.js +1 -0
  10. package/dist/lib/ldap/schema.js +1 -0
  11. package/dist/lib/ldap/searchCache.js +1 -0
  12. package/dist/lib/ldapActions.js +1 -1
  13. package/dist/lib/parseConfig.js +1 -1
  14. package/dist/lib/services/index.js +1 -0
  15. package/dist/lib/utils.js +1 -1
  16. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  17. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  18. package/dist/plugins/ldap/groups.js +1 -1
  19. package/dist/plugins/ldap/organizations.js +1 -1
  20. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  21. package/dist/plugins/ldap/raw.js +1 -1
  22. package/dist/plugins/ldap/trash.js +1 -1
  23. package/dist/plugins/scim/mapping.js +1 -1
  24. package/dist/plugins/scim/patch.js +1 -1
  25. package/dist/plugins/storage/file.js +1 -1
  26. package/dist/plugins/twake/instances.js +1 -1
  27. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  28. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  29. package/dist/plugins/twake/tombstone.js +1 -1
  30. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  31. package/dist/src/abstract/plugin.d.ts +21 -0
  32. package/dist/src/bin/index.d.ts +18 -0
  33. package/dist/src/config/options.d.ts +16 -1
  34. package/dist/src/lib/errors.d.ts +2 -1
  35. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  36. package/dist/src/lib/ldap/pool.d.ts +108 -0
  37. package/dist/src/lib/ldap/schema.d.ts +157 -0
  38. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  39. package/dist/src/lib/ldapActions.d.ts +25 -262
  40. package/dist/src/lib/parseConfig.d.ts +26 -2
  41. package/dist/src/lib/services/index.d.ts +56 -0
  42. package/dist/src/lib/utils.d.ts +72 -3
  43. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  44. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  45. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  46. package/dist/src/plugins/scim/types.d.ts +2 -0
  47. package/dist/src/plugins/twake/instances.d.ts +31 -0
  48. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  49. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  50. package/docs/README.md +16 -2
  51. package/docs/client-development/api/rest-api.md +4 -0
  52. package/docs/development/contracts.md +109 -0
  53. package/docs/plugin-development/README.md +8 -1
  54. package/docs/plugin-development/dependencies.md +13 -2
  55. package/docs/plugin-development/hooks.md +2 -1
  56. package/docs/plugin-development/options.md +40 -13
  57. package/docs/plugin-development/services.md +95 -0
  58. package/docs/usage/operations.md +9 -0
  59. package/docs/usage/plugins/integrations/instances.md +11 -3
  60. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  61. package/docs/usage/plugins/integrations/scim.md +20 -18
  62. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  63. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  64. package/docs/usage/plugins/ldap/groups.md +4 -1
  65. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  66. package/docs/usage/upgrading.md +59 -0
  67. package/package.json +17 -12
  68. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  69. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  70. package/static/browser/ldap-tree-viewer.js.map +1 -1
  71. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Typed services between plugins: a token names the interface a consumer
3
+ * reads and the instance a provider offers, so the compiler checks both.
4
+ *
5
+ * @see docs/plugin-development/services.md
6
+ */
7
+ declare const serviceType: unique symbol;
8
+ /**
9
+ * What identifies a service: a name for the logs, and the interface it
10
+ * answers. The type is carried by a phantom property and never exists at
11
+ * runtime; the object itself is the registry key.
12
+ */
13
+ export interface ServiceToken<T> {
14
+ /** Names the service in the logs */
15
+ readonly name: string;
16
+ /** Type only, never set: carries the interface a consumer reads */
17
+ readonly [serviceType]?: T;
18
+ /**
19
+ * Bind the instance providing this service, as a plugin's `provides`
20
+ * declares. The argument must answer the interface the token names.
21
+ *
22
+ * @param provider the plugin instance providing the service
23
+ * @returns the token and its provider
24
+ */
25
+ with(provider: T): ServiceProvider<T>;
26
+ }
27
+ /** A token with the instance providing it, as a plugin declares them. */
28
+ export interface ServiceProvider<T> {
29
+ readonly token: ServiceToken<T>;
30
+ readonly service: T;
31
+ }
32
+ /**
33
+ * Declare a service.
34
+ *
35
+ * Declare a token once, in a module both the provider and its consumers
36
+ * import: the object is the registry key, and a second declaration under the
37
+ * same name would be another service. The name only identifies it in the
38
+ * logs.
39
+ *
40
+ * @param name the service name
41
+ * @returns the token, carrying its interface
42
+ */
43
+ export declare const serviceToken: <T>(name: string) => ServiceToken<T>;
44
+ /** What `DM.services` holds: the provided instance for each token. */
45
+ export type ServiceMap = Map<ServiceToken<unknown>, unknown>;
46
+ /**
47
+ * The instance provided for a token, when a plugin provides it.
48
+ *
49
+ * A provider always answers something: `undefined` means nobody provides it.
50
+ *
51
+ * @param services the server's registry
52
+ * @param token the service wanted
53
+ * @returns the provided instance, or undefined
54
+ */
55
+ export declare const providedService: <T>(services: ReadonlyMap<ServiceToken<unknown>, unknown>, token: ServiceToken<T>) => T | undefined;
56
+ export {};
@@ -155,6 +155,30 @@ export declare function parseDn(dn: string): string[];
155
155
  * @returns whether it reads as a DN
156
156
  */
157
157
  export declare function isDnSyntax(dn: string): boolean;
158
+ /**
159
+ * Whether every escape in `dn` is one RFC 4514 §3 defines: an escaped
160
+ * special (`" + , ; < > \ # =` or a space) or a pair of hex digits.
161
+ *
162
+ * {@link parseDn} and {@link unescapeDnValue} read a DN permissively, as a
163
+ * directory may answer one; a DN a caller writes is held to the grammar.
164
+ *
165
+ * @param dn - The DN to check
166
+ * @returns whether its escapes are RFC 4514 escapes
167
+ */
168
+ export declare function hasOnlyRfc4514Escapes(dn: string): boolean;
169
+ /**
170
+ * Refuse a DN a client wrote when it holds an escape RFC 4514 does not
171
+ * define.
172
+ *
173
+ * The readers here are permissive on purpose, because they also read what a
174
+ * directory answers. A DN a caller sends is held to the grammar instead, so
175
+ * the server only ever writes a spelling the directory would produce itself.
176
+ *
177
+ * @param dn - The DN the client wrote
178
+ * @param what - What to name in the refusal
179
+ * @throws BadRequestError when an escape is not one RFC 4514 defines
180
+ */
181
+ export declare function assertRfc4514Escapes(dn: string, what: string): void;
158
182
  /**
159
183
  * Extract the parent DN from a DN
160
184
  * Removes the first RDN component to get the parent branch
@@ -206,9 +230,11 @@ export declare function rdnValue(dn: string): string;
206
230
  * Read the name of an entry written as a DN: the value of its first RDN when
207
231
  * that RDN asserts `attribute` alone, under `base` when one is given.
208
232
  *
209
- * The value is decoded exactly as {@link rdnValue} decodes it: it is the
210
- * naming attribute's value, the one a write puts back in
211
- * `attribute=…,base`.
233
+ * The value is decoded as {@link rdnValue} decodes it, then folded as the
234
+ * directory folds it ({@link foldRdnValue}): it is the naming attribute's
235
+ * value, the one a write puts back in `attribute=…,base`, and a caller
236
+ * spelling it in a way the directory considers the same name gets that name,
237
+ * not a second one.
212
238
  *
213
239
  * @param dn - The DN to read
214
240
  * @param attribute - The attribute the first RDN must assert
@@ -249,6 +275,26 @@ export declare function namesByDn(id: string, attribute: string): boolean;
249
275
  * ```
250
276
  */
251
277
  export declare function isChildOf(dn: string, parentDn: string): boolean;
278
+ /**
279
+ * Whether `dn` names a direct child of `base`: its parent is `base`, compared
280
+ * as the directory compares DNs.
281
+ *
282
+ * `isChildOf` answers ancestry; this answers parent equality, which is what a
283
+ * route that only serves one branch requires of a DN a client writes. The
284
+ * comparison folds case, spacing and the escapes the directory reads, so a
285
+ * DN created under one spelling is addressed under another.
286
+ *
287
+ * @param dn - The DN to check
288
+ * @param base - The branch its parent has to be
289
+ * @returns True when the DN has a parent and that parent is `base`
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * isDirectChildOf('uid=x,OU=Users , dc=example,dc=com', 'ou=users,dc=example,dc=com')
294
+ * // => true
295
+ * ```
296
+ */
297
+ export declare function isDirectChildOf(dn: string, base: string): boolean;
252
298
  /**
253
299
  * Teach {@link normalizeDn} the names and OIDs of attribute types: each is
254
300
  * read as the type's first name.
@@ -299,6 +345,15 @@ export declare function rdnTypes(dn: string): string[];
299
345
  * ```
300
346
  */
301
347
  export declare function normalizeDn(dn: string): string;
348
+ /**
349
+ * A DN's structure normalized as {@link normalizeDn} does, with the case of
350
+ * its values kept.
351
+ *
352
+ * A configured expression compiled to ignore case reads a DN through this
353
+ * form: its values are folded as the directory folds them, but a value it
354
+ * captures keeps the spelling the caller wrote.
355
+ */
356
+ export declare function canonicalDn(dn: string): string;
302
357
  /**
303
358
  * Whether a member DN is the placeholder a `groupOfNames` holds so it stays
304
359
  * valid with no real member (`--group-dummy-user`).
@@ -374,6 +429,20 @@ export declare function isDnInBranch(dn: string, base: string): boolean;
374
429
  * @returns true when the entry entered the trash branch
375
430
  */
376
431
  export declare function movedToTrash(trashBase: string | undefined, oldDn: string, newDn: string): boolean;
432
+ /**
433
+ * Parse a directory date: an LDAP generalized time or anything `Date`
434
+ * understands.
435
+ *
436
+ * A generalized time (RFC 4517 section 3.3.13) is `yyyyMMddHH`, then optional
437
+ * minutes and seconds, an optional fraction of the last unit given, and a zone:
438
+ * `Z` or an offset of hours with optional minutes (`+02`, `-0530`). `Date` reads
439
+ * none of the shorter forms, and not an offset of bare hours, so they are
440
+ * computed here rather than rewritten into an ISO string it might refuse.
441
+ *
442
+ * @param raw value as stored or submitted
443
+ * @returns the date, or null when it cannot be read
444
+ */
445
+ export declare function parseDirectoryDate(raw: AttributeValue): Date | null;
377
446
  /**
378
447
  * Wrapper for async Express route handlers to catch errors and pass them to error middleware
379
448
  * This ensures that errors in async routes are properly handled and don't crash the server
@@ -107,20 +107,6 @@ export declare function parseByteSize(raw: AttributeValue): number;
107
107
  * @returns true when the rules supply a value
108
108
  */
109
109
  export declare function fillsGeneratedAttribute(name: string, attr: SchemaAttribute, schema: Schema): boolean;
110
- /**
111
- * Parse a directory date: an LDAP generalized time or anything `Date`
112
- * understands.
113
- *
114
- * A generalized time (RFC 4517 section 3.3.13) is `yyyyMMddHH`, then optional
115
- * minutes and seconds, an optional fraction of the last unit given, and a zone:
116
- * `Z` or an offset of hours with optional minutes (`+02`, `-0530`). `Date` reads
117
- * none of the shorter forms, and not an offset of bare hours, so they are
118
- * computed here rather than rewritten into an ISO string it might refuse.
119
- *
120
- * @param raw value as stored or submitted
121
- * @returns the date, or null when it cannot be read
122
- */
123
- export declare function parseDirectoryDate(raw: AttributeValue): Date | null;
124
110
  /**
125
111
  * Start of a day, in UTC.
126
112
  *
@@ -353,9 +353,10 @@ export default class PasswordPolicy extends DmPlugin {
353
353
  private deletePolicy;
354
354
  private assignPolicy;
355
355
  /**
356
- * Parse GeneralizedTime (YYYYMMDDHHmmssZ) to Date
356
+ * A time the directory wrote, or null when the entry holds none, read as
357
+ * the rest of LDAP-Rest reads one: see {@link parseDirectoryDate}.
357
358
  */
358
- private parseGeneralizedTime;
359
+ private parsePolicyTime;
359
360
  /**
360
361
  * Validate password complexity (optional local validation)
361
362
  */
@@ -27,9 +27,11 @@ export declare function withExternalId(mapping: ResourceMapping, ldapAttribute:
27
27
  * `xsd:dateTime` and reject anything else, so the raw directory value must not
28
28
  * be forwarded as-is.
29
29
  *
30
- * A value that does not look like a GeneralizedTime is returned untouched: a
31
- * directory may already store an ISO 8601 string, and dropping it would lose
32
- * more than passing it through.
30
+ * The instant is read by {@link parseDirectoryDate}, as every other directory
31
+ * time is; this only writes it in the form SCIM asks for, in UTC. A value that
32
+ * does not look like a GeneralizedTime is returned untouched: a directory may
33
+ * already store an ISO 8601 string, and dropping it would lose more than
34
+ * passing it through.
33
35
  */
34
36
  export declare function ldapTimeToIso(value: string | undefined): string | undefined;
35
37
  export declare function loadMappingFile(path: string): ResourceMapping | undefined;
@@ -163,6 +165,17 @@ export declare function scimGroupToLdap(group: ScimGroup, mapping: ResourceMappi
163
165
  * mapped SCIM attributes can be populated.
164
166
  */
165
167
  export declare function requiredLdapAttributes(mapping: ResourceMapping, extra?: string[]): string[];
168
+ /**
169
+ * Refuse a DN a client wrote in a mapped reference when it holds an escape
170
+ * RFC 4514 does not define.
171
+ *
172
+ * The mapping says which sub-attributes carry a DN (`subDn`): a reference
173
+ * such as `manager.value` is written to the directory as sent, where the
174
+ * entity validator that holds the flat routes to RFC 4514 never sees it.
175
+ */
176
+ export declare function assertReferenceDn(value: string, what: string): void;
177
+ /** Whether the mapping declares this sub-attribute a DN (`subDn`). */
178
+ export declare function isDnSubAttribute(mapping: ResourceMapping, top: string, sub: string): boolean;
166
179
  /**
167
180
  * Given a SCIM attribute path like "emails.value" or "name.familyName",
168
181
  * return the corresponding LDAP attribute name (used by filter parser).
@@ -147,6 +147,8 @@ export interface MappingEntry {
147
147
  ldapPrimary?: string;
148
148
  ldapSecondary?: string;
149
149
  sub?: Record<string, string>;
150
+ /** Sub-attributes whose value is a DN, held to RFC 4514 on write */
151
+ subDn?: string[];
150
152
  multi?: 'single' | 'array';
151
153
  readOnly?: boolean;
152
154
  operational?: boolean;
@@ -12,6 +12,8 @@ export interface WorkplaceCreated {
12
12
  /** An account's instance request, and the id `user.created` names it by. */
13
13
  interface Account extends InstanceRequest {
14
14
  twakeId: string;
15
+ /** The organization id came from the DN, not from the provider settings */
16
+ fromDn?: boolean;
15
17
  }
16
18
  export declare const EnsureInstanceBody: import("@sinclair/typebox").TObject<{
17
19
  dn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -128,12 +130,41 @@ export default class TwakeInstances extends DmPlugin {
128
130
  private get organizationFqdn();
129
131
  private organizationMail;
130
132
  private isOrganization;
133
+ /**
134
+ * The id the organization's own entry spells, for an id captured from a
135
+ * client's DN: the caller may write the branch in another case, and the
136
+ * Cloudery branch and the slug should not follow it. The captured value
137
+ * stands when there is no entry or no organization base.
138
+ */
139
+ private storedOrganizationId;
140
+ /** `account()`, with a captured organization id read from its entry. */
141
+ private storedAccount;
131
142
  private findOrganization;
132
143
  private read;
133
144
  protected announce(exchange: string, key: string, message: Record<string, unknown>): Promise<void>;
134
145
  /** What to ask for this account, when it gets an instance at all. */
135
146
  protected account(dn: string, entry: AttributesList): Account | undefined;
147
+ /**
148
+ * The expression that takes this DN for an account's, and where it sits.
149
+ *
150
+ * Each expression reads the canonical DN first: a client may spell the
151
+ * branch differently, and a space or an escape inside a captured value
152
+ * would otherwise be captured as written. The canonical form keeps the
153
+ * caller's case, which an organization id is sent as. An expression written
154
+ * with the directory's own spelling — `\2C`, a doubled space — does not
155
+ * match that form, so the DN as sent is tried next.
156
+ */
136
157
  private match;
158
+ /**
159
+ * The DN with a comma inside a value written `\2C`, as the directory writes
160
+ * it: an expression's `[^,]` cannot then read that comma as an RDN
161
+ * separator and name the branch of an entry beside it.
162
+ */
163
+ private separators;
164
+ /** The uid of an account's DN, under any name or alias of the type. */
165
+ private uid;
166
+ /** One queue per entry, whatever spelling its DN came in. */
167
+ private key;
137
168
  private id;
138
169
  protected value(entry: AttributesList, attribute: string): unknown;
139
170
  protected findByMail(email: string, req?: Request): Promise<AttributesList | undefined>;
@@ -25,5 +25,16 @@ export declare function first(value: AttributeValue | null | undefined): string
25
25
  export declare const isTombstone: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
26
26
  export declare const isLocked: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
27
27
  export declare function formatDeletedAt(date: Date, format: DeletedAtFormat): string;
28
- /** A deletion date as the directory holds it, or undefined if unreadable. */
29
- export declare function parseDeletedAt(value: string, format: DeletedAtFormat): Date | undefined;
28
+ /**
29
+ * A deletion date as the directory holds it, or undefined if unreadable.
30
+ *
31
+ * Read by the same reader as every other directory time: the format only says
32
+ * how this deployment writes the date, not how it is read.
33
+ *
34
+ * A value of another shape is refused here rather than handed to that
35
+ * reader, whose fallback to `new Date()` reads `1` or `Jan 1` as a date: the
36
+ * erase guard takes an unreadable date for a refusal. The same fallback rolls
37
+ * `2025-02-31` over to March, where the shared reader refuses that day
38
+ * spelled as a GeneralizedTime.
39
+ */
40
+ export declare function parseDeletedAt(value: string): Date | undefined;
@@ -48,8 +48,8 @@ export default class TwakeTombstone extends DmPlugin {
48
48
  */
49
49
  tombstone(dn: string, reason: string, req?: Request): Promise<void>;
50
50
  /**
51
- * Remove a tombstone and its group memberships. Refused while the deletion
52
- * is younger than the configured age, unless forced.
51
+ * Remove a tombstone and its group memberships. Refused when its deletion
52
+ * date is unreadable or younger than the configured age, unless forced.
53
53
  */
54
54
  erase(dn: string, { force, req }?: {
55
55
  force?: boolean;
@@ -77,6 +77,13 @@ export default class TwakeTombstone extends DmPlugin {
77
77
  * group the erase left holding only the placeholder loses it again.
78
78
  */
79
79
  private restore;
80
+ /**
81
+ * Whether this DN names an entry the expressions keep. A client may spell
82
+ * the DN differently from the expression; the directory stores the entry
83
+ * at its canonical DN, so the expressions read that too. The DN as sent is
84
+ * still tried: an expression written with the directory's own spelling
85
+ * (`\2C`, a doubled space) matches only that form.
86
+ */
80
87
  private matches;
81
88
  private isScim;
82
89
  private checkReason;
package/docs/README.md CHANGED
@@ -20,6 +20,7 @@ Create and extend LDAP-Rest with custom plugins.
20
20
 
21
21
  - **[Development Guide](plugin-development/README.md)** - Architecture and plugin creation
22
22
  - **[Hooks](plugin-development/hooks.md)** - LDAP hooks system
23
+ - **[Services](plugin-development/services.md)** - Typed interfaces between plugins
23
24
  - **[Routes](plugin-development/routes.md)** - Declaring routes and their input schemas
24
25
  - **[Testing](plugin-development/testing.md)** - Testing with embedded LDAP
25
26
  - **[Dependencies](plugin-development/dependencies.md)** - Plugin dependencies matrix
@@ -33,6 +34,13 @@ Integrate LDAP-Rest into your web applications.
33
34
  - **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation
34
35
  - **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration
35
36
 
37
+ ### [Development](development/contracts.md)
38
+
39
+ Work on ldap-rest itself.
40
+
41
+ - **[Published contracts](development/contracts.md)** - What breaks a
42
+ contract, the snapshots that detect it and the deprecation window.
43
+
36
44
  ## API stability
37
45
 
38
46
  From 1.0.0, ldap-rest follows semantic versioning. These are its public
@@ -44,8 +52,9 @@ API, which a minor or a patch release does not break:
44
52
  [REST API](client-development/api/rest-api.md) and the plugin pages,
45
53
  their statuses and their error `code`s.
46
54
  - **Plugin API**: what `ldap-rest/plugin` exports — `DmPlugin` and its
47
- roles (`auth`, `authz`, …), `asyncHandler`, the DN and filter escaping
48
- helpers and the error classes —, the `DM` type of `ldap-rest` and the
55
+ roles (`auth`, `authz`, …), `serviceToken`, `ServiceToken` and
56
+ `ServiceProvider`, `asyncHandler`, the DN and filter escaping helpers and
57
+ the error classes —, the `DM` type of `ldap-rest` and the
49
58
  `Hooks` of `ldap-rest/hooks`, whose arguments are those typed in
50
59
  `src/hooks.ts` ([hooks](plugin-development/hooks.md)), and the directory
51
60
  operations of [the development guide](plugin-development/README.md):
@@ -58,6 +67,11 @@ API, which a minor or a patch release does not break:
58
67
  Everything else — other exports, internal helpers, log messages — may
59
68
  change in any release.
60
69
 
70
+ The options and the REST API are checked mechanically: committed snapshots
71
+ are compared with the base branch of each pull request, and a non-additive
72
+ change needs the `breaking` label. The plugin types are still reviewed by
73
+ hand. See [Published contracts](development/contracts.md).
74
+
61
75
  ## Presentations
62
76
 
63
77
  Interactive presentations (using [presenterm](https://github.com/mfontanini/presenterm)):
@@ -1365,6 +1365,10 @@ const encoded = encodeURIComponent(dn);
1365
1365
  // Result: uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom
1366
1366
  ```
1367
1367
 
1368
+ A path parameter is percent-decoded once, as RFC 3986 spells it: an
1369
+ identifier holding `%` is sent as `%25`, so `100%` is addressed as
1370
+ `/api/v1/ldap/users/100%25`.
1371
+
1368
1372
  ### LDAP Attributes are Arrays
1369
1373
 
1370
1374
  LDAP attributes are always returned as arrays, even for single-valued attributes:
@@ -0,0 +1,109 @@
1
+ # Published contracts
2
+
3
+ Other code depends on three things ldap-rest publishes. A change to one that
4
+ is not additive breaks that code, so it needs a major release and the pull
5
+ request says so before it merges.
6
+
7
+ - **Configuration**: the CLI options and `DM_*` variables of
8
+ [Configuration](../usage/configuration.md) and of each plugin's page.
9
+ - **REST API**: the routes, request bodies and answers of the
10
+ [REST API](../client-development/api/rest-api.md), their parameters,
11
+ responses (references resolved), statuses and response headers. The error
12
+ `code` values are reviewed by hand: the document types them as strings.
13
+ - **Plugin API**: what the npm sub-path exports declare — `ldap-rest`,
14
+ `./hooks`, `./plugin`, `./ldapactions`, `./expressformatedresponses`, the
15
+ `./plugin-*`, `./abstract-*` and `./browser-*` entries — and the JSON
16
+ schema format of the `./schema-*` entries.
17
+
18
+ [API stability](../README.md#api-stability) says what a minor or patch
19
+ release promises. This page says how a change is detected, and what to do
20
+ when it is not additive.
21
+
22
+ ## Committed snapshots
23
+
24
+ Two snapshots are generated locally and checked in CI, so a change to a
25
+ contract shows up as a diff instead of a surprise after the release:
26
+
27
+ - `docs/usage/options.snapshot.json` lists every option's CLI name, plural
28
+ form, environment variable, type, default, kind, required flag and aliases.
29
+ Regenerate it with `npm run snapshot:options`; `npm run check:options`, part
30
+ of `npm run check`, fails when it is stale.
31
+ - `openapi.json` is the REST API document the published reference is built
32
+ from. Regenerate it with `npm run generate:openapi`; `npm run check:openapi`
33
+ fails when it is stale.
34
+
35
+ The plugin types have no snapshot yet: one api-extractor report per sub-path
36
+ export is a change of its own. Until it lands, a change to an exported type is
37
+ reviewed by hand.
38
+
39
+ The snapshot records what the parser reads. A narrowed `choices` list, a
40
+ raised `min` or a lowered `max` is checked when a plugin reads its options,
41
+ not by the snapshot, and is reviewed by hand, as is a custom error `code`.
42
+
43
+ ## The pull request gate
44
+
45
+ The `contracts` job of `.github/workflows/contracts.yml` generates the
46
+ snapshots from the pull request and compares them with the base branch's
47
+ committed ones. It reports:
48
+
49
+ - additive changes — a new option, route or field — and lets them through;
50
+ - non-additive changes, and fails unless the pull request carries the
51
+ `breaking` label.
52
+
53
+ A change is non-additive when it removes or narrows what was published:
54
+ a removed option or environment variable, a changed type, default, bound,
55
+ `const`, enum or required flag, a request value that becomes an enum, a
56
+ request object that stops accepting additional properties, a removed route,
57
+ method, parameter, response status, response header or field, a request field
58
+ that becomes required, a response that may now be null, changed security
59
+ requirements, a removed union variant, a value an alternative of the type
60
+ closes with an enum or a `const` where the type used to accept any, an
61
+ untyped request value that becomes a union, an `allOf` that adds a
62
+ constraint, or — once the plugin snapshot exists — a removed export or a
63
+ narrowed signature. Renaming an option is a deprecation, not a removal, as
64
+ long as the old CLI name and environment variable stay in its `aliases` and
65
+ `envAliases`. Adding a union variant, a field or an optional parameter is
66
+ additive, and so is a request union with an alternative that still accepts
67
+ everything the base did — in a response the client reads every alternative,
68
+ so a field the others drop is still a removal. A `oneOf` is the exception:
69
+ it refuses a value two alternatives match, so an alternative as wide as the
70
+ base narrows the others away, and the alternatives are not told apart from
71
+ one another — a disjoint `oneOf` therefore asks for the label too.
72
+ Documentation-only edits, such as a summary or description, are ignored.
73
+
74
+ The job runs the scripts from the pull request, so a change to them is
75
+ reviewed like any other code, as `.github/CODEOWNERS` requires.
76
+
77
+ The `breaking` label records that the maintainers accepted the break, and the
78
+ `CHANGELOG.md` section must say so: the release gate below reads the section,
79
+ not the label, and refuses to ship the break as a minor or patch release. The
80
+ workflow listens to label events, so adding the label runs the job again; it
81
+ has its own concurrency group, so that event does not cancel the test matrix.
82
+
83
+ To try the comparison locally against your base branch:
84
+
85
+ ```bash
86
+ npx tsx scripts/contracts.ts --base origin/master
87
+ ```
88
+
89
+ ## The release gate
90
+
91
+ `.github/workflows/release.yml` runs `scripts/check-release.ts` on the tag. A
92
+ non-major tag whose `CHANGELOG.md` section lists `### Breaking Changes` is
93
+ refused. For a 0.x version, a minor release counts as major, as semantic
94
+ versioning lets it break. When the tag has no section of its own, the gate
95
+ reads `## Unreleased`, so a tag cut before the section was renamed is still
96
+ checked; with neither section it fails. When the CHANGELOG lists no earlier
97
+ release, only a first `X.0.0` counts as major, so a trimmed file cannot open
98
+ the gate.
99
+
100
+ ## Deprecating instead of breaking
101
+
102
+ The window and the rules are in
103
+ [CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract).
104
+ The mechanics of an option alias are in
105
+ [plugin options](../plugin-development/options.md#deprecating-an-option): the
106
+ old CLI name and environment variable go in `aliases` and `envAliases`, and
107
+ the server warns at startup while it still reads them. An array option's
108
+ plural form has no alias slot: renaming it is a breaking change, so it waits
109
+ for a major release.
@@ -7,6 +7,7 @@ This guide will help you create powerful, production-ready plugins for LDAP-Rest
7
7
  - [Why Create a Plugin](#why-create-a-plugin)
8
8
  - [Plugin Structure](#plugin-structure)
9
9
  - [Creating Your Plugin](#creating-your-plugin)
10
+ - [Plugin services](./services.md)
10
11
  - [Custom Configuration Options](#custom-configuration-options)
11
12
  - [Plugin options](./options.md)
12
13
  - [Loading Your Plugin](#loading-your-plugin)
@@ -54,6 +55,8 @@ export default class MyPlugin extends DmPlugin {
54
55
  - **roles** (optional): Array categorizing plugin purpose
55
56
  - **dependencies** (optional): Required plugins that must load first
56
57
  - **hooks** (optional): Callbacks for LDAP operations
58
+ - **provides** (optional): Typed services this plugin offers, see
59
+ [plugin services](services.md)
57
60
  - **api()** (optional): Method to register REST endpoints
58
61
 
59
62
  ### Available from Base Class
@@ -65,6 +68,8 @@ export default class MyPlugin extends DmPlugin {
65
68
  - **this.logger**: Winston logger instance
66
69
  - **this.hookBus**: The server's hooks: chain, notify and wait on them, see
67
70
  [hooks](hooks.md#the-bus)
71
+ - **this.service(token)**: The instance another plugin provides under a
72
+ service token, or `null`, see [plugin services](services.md)
68
73
  - **this.fetch**: `fetch` timed into the
69
74
  [outbound metrics](../usage/operations.md#metrics) under the plugin's
70
75
  name; the lint refuses a bare `fetch` or `node-fetch` under `src`, and a
@@ -452,7 +457,8 @@ const results = await this.server.ldap.search(
452
457
  'ou=users,dc=example,dc=com'
453
458
  );
454
459
 
455
- // Search with pagination (returns AsyncGenerator)
460
+ // Search with pagination (returns AsyncGenerator; the connection is taken
461
+ // when the first page is read)
456
462
  const pagedResults = await this.server.ldap.search(
457
463
  {
458
464
  paged: true,
@@ -1320,6 +1326,7 @@ See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
1320
1326
 
1321
1327
  - [Contributing Guide](../../CONTRIBUTING.md) - Git workflow and PR guidelines
1322
1328
  - [Hooks Reference](./hooks.md) - Complete list of available hooks
1329
+ - [Plugin services](./services.md) - Typed interfaces between plugins
1323
1330
  - [Core Plugin Examples](../../src/plugins/) - Browse existing plugins
1324
1331
  - [Test Examples](../../test/plugins/) - See how core plugins are tested
1325
1332
 
@@ -1,9 +1,10 @@
1
1
  # Plugin Dependency Graph
2
2
 
3
- This document describes the dependencies between LDAP-Rest plugins. There are two types of dependencies:
3
+ This document describes the dependencies between LDAP-Rest plugins. There are three types of dependencies:
4
4
 
5
5
  1. **Explicit Dependencies**: Defined in the `dependencies` property of a plugin class
6
- 2. **Hook Dependencies**: Implicit dependencies through hooks (one plugin provides a hook, another consumes it)
6
+ 2. **Service Dependencies**: A plugin reaches a typed interface another one provides, see [plugin services](services.md)
7
+ 3. **Hook Dependencies**: Implicit dependencies through hooks (one plugin provides a hook, another consumes it)
7
8
 
8
9
  ## Dependency Types
9
10
 
@@ -24,6 +25,16 @@ overrides — a dependency also given to `--plugin`, or an entry listed twice
24
25
  is loaded once: the second request waits for the first. Only an entry with
25
26
  overrides builds a second instance, and it needs a name of its own.
26
27
 
28
+ ### Service Dependencies
29
+
30
+ A plugin provides a typed interface under a token, and a consumer reaches it
31
+ with `this.service(token)`. The token replaces the registry name as the way to
32
+ find another plugin's code; `dependencies` still decides what loads first. A
33
+ consumer whose provider is absent gets `null` and a single warning.
34
+
35
+ See [plugin services](services.md) for the token, the provided interface and
36
+ the registration rules.
37
+
27
38
  ### Hook Dependencies
28
39
 
29
40
  **Hook Providers**: Plugins that implement hooks (via `hooks: Hooks = { ... }`)
@@ -60,7 +60,7 @@ await server.hookBus.settled();
60
60
  - **hello**: raised by the [demo plugin](../../src/plugins/demo/helloworld.ts) for `/hello`, answered by the demo plugins
61
61
  - **ldapsearchopts**: chained before any search, with its options
62
62
  - **ldapsearchrequest**: chained before any search, with `[base, options, request]`; an authorization plugin refuses by throwing
63
- - **ldapsearchresult**: chained after any search, with its result, before it is cached
63
+ - **ldapsearchresult**: chained after any search, with its result, before it is cached; for a paged search, when the first page is read
64
64
  - **ldapsearchfilter**: chained on the result of a search after the cache, once per page, with `[result, request, options]`, to drop what the caller may not see
65
65
  - **ldapaddrequest**: chained before any add, with `[dn, entry, request]`
66
66
  - **ldapadddone**: notified once an add has written the entry, with the DN, the entry and the context, one by one; not awaited
@@ -118,6 +118,7 @@ await server.hookBus.settled();
118
118
  - **scimgroupdeletedone**: notified once a SCIM group is deleted, with its id
119
119
  - **scimbulkdone**: notified once a SCIM bulk request is answered
120
120
  - **twakedeletionreplay**: notified with the DN, the entry and the context, one by one when a delete asks of an entry that is already a tombstone
121
+
121
122
  <!-- /hooks -->
122
123
 
123
124
  ## Flat entities