ldap-rest 1.3.0 → 2.0.1

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 (82) hide show
  1. package/README.md +8 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/plugin.js +1 -1
  4. package/dist/bin/index.js +1 -1
  5. package/dist/config/options.js +1 -1
  6. package/dist/config/schema.js +1 -1
  7. package/dist/lib/authz/base.js +1 -1
  8. package/dist/lib/errors.js +1 -1
  9. package/dist/lib/ldap/callerView.js +1 -0
  10. package/dist/lib/ldap/pool.js +1 -0
  11. package/dist/lib/ldap/schema.js +1 -0
  12. package/dist/lib/ldap/searchCache.js +1 -0
  13. package/dist/lib/ldapActions.js +1 -1
  14. package/dist/lib/parseConfig.js +1 -1
  15. package/dist/lib/services/index.js +1 -0
  16. package/dist/lib/utils.js +1 -1
  17. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  18. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  19. package/dist/plugins/ldap/groups.js +1 -1
  20. package/dist/plugins/ldap/organizations.js +1 -1
  21. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  22. package/dist/plugins/ldap/raw.js +1 -1
  23. package/dist/plugins/ldap/trash.js +1 -1
  24. package/dist/plugins/scim/mapping.js +1 -1
  25. package/dist/plugins/scim/patch.js +1 -1
  26. package/dist/plugins/storage/file.js +1 -1
  27. package/dist/plugins/twake/instances.js +1 -1
  28. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  29. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  30. package/dist/plugins/twake/tombstone.js +1 -1
  31. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  32. package/dist/src/abstract/plugin.d.ts +21 -0
  33. package/dist/src/bin/index.d.ts +38 -4
  34. package/dist/src/config/options.d.ts +16 -1
  35. package/dist/src/lib/errors.d.ts +2 -1
  36. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  37. package/dist/src/lib/ldap/pool.d.ts +108 -0
  38. package/dist/src/lib/ldap/schema.d.ts +157 -0
  39. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  40. package/dist/src/lib/ldapActions.d.ts +25 -262
  41. package/dist/src/lib/parseConfig.d.ts +26 -2
  42. package/dist/src/lib/services/index.d.ts +56 -0
  43. package/dist/src/lib/utils.d.ts +72 -3
  44. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  45. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  46. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  47. package/dist/src/plugins/scim/types.d.ts +2 -0
  48. package/dist/src/plugins/twake/instances.d.ts +31 -0
  49. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  50. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  51. package/docs/README.md +25 -2
  52. package/docs/architecture/README.md +180 -0
  53. package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
  54. package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
  55. package/docs/architecture/adr/0003-authorization-composition.md +59 -0
  56. package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
  57. package/docs/architecture/adr/0005-hook-contract.md +54 -0
  58. package/docs/architecture/adr/0006-search-cache.md +49 -0
  59. package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
  60. package/docs/architecture/adr/0008-withconfig-views.md +58 -0
  61. package/docs/client-development/api/rest-api.md +4 -0
  62. package/docs/development/contracts.md +119 -0
  63. package/docs/development/modularization-plan.md +197 -0
  64. package/docs/plugin-development/README.md +8 -1
  65. package/docs/plugin-development/dependencies.md +13 -2
  66. package/docs/plugin-development/hooks.md +4 -2
  67. package/docs/plugin-development/options.md +40 -13
  68. package/docs/plugin-development/services.md +95 -0
  69. package/docs/usage/operations.md +12 -0
  70. package/docs/usage/plugins/integrations/instances.md +11 -3
  71. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  72. package/docs/usage/plugins/integrations/scim.md +20 -18
  73. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  74. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  75. package/docs/usage/plugins/ldap/groups.md +4 -1
  76. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  77. package/docs/usage/upgrading.md +59 -0
  78. package/package.json +21 -12
  79. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  80. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  81. package/static/browser/ldap-tree-viewer.js.map +1 -1
  82. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -0,0 +1,43 @@
1
+ # 0002 — Authentication dispatch
2
+
3
+ ## Context
4
+
5
+ One server can host populations that authenticate differently: administrators
6
+ behind a token, users behind OpenID Connect, machine clients behind HMAC. An
7
+ authentication plugin declares the path prefixes it claims. A request must run
8
+ the authentication that owns its path, and only that one: running two
9
+ unrelated authenticators on one request composes as an AND that no credential
10
+ can satisfy. A request no plugin claims must not silently become open without
11
+ saying so.
12
+
13
+ ## Decision
14
+
15
+ `DM` mounts one dispatcher layer, once, at the boundary between the protection
16
+ plugins and the routes. For each request it selects the plugins whose prefix
17
+ is the longest match. Several plugins sharing that winning prefix all run, in
18
+ registration order, and every one must let the request through. When no
19
+ plugin claims the path, the unscoped plugins (no prefix) run; when there is
20
+ none, the request passes through unauthenticated, outside any request
21
+ context. A plugin's `afterChain` runs after the whole chain and before the
22
+ route. `runForRequest` then wraps everything downstream of a request an
23
+ authenticator let through, and the dispatcher records which plugins vouched
24
+ for the request in `req.authenticators`.
25
+
26
+ `warnUnauthenticatedRoutes` lists, at startup, the routes that no
27
+ authentication plugin covers. An unscoped authenticator guards everything and
28
+ silences the warning.
29
+
30
+ ## Consequences
31
+
32
+ - Two authenticators on one prefix require both credentials. That is
33
+ legitimate — a session and a token — but it fails closed when accidental.
34
+ - Scoping authentication to a prefix opens every route outside it; the
35
+ startup warning is the only signal, and it names the routes.
36
+ - `afterChain` is the only hook that sees all the selected authenticators'
37
+ verdicts, so an answer that depends on several of them belongs there, not
38
+ in a plugin's own middleware.
39
+ - An authorization plugin can tell whose request it judges from
40
+ `req.authenticators`; see
41
+ [authorization composition](0003-authorization-composition.md).
42
+
43
+ See `src/bin/index.ts` (`dispatchAuth`) and `src/lib/auth/base.ts`.
@@ -0,0 +1,59 @@
1
+ # 0003 — Authorization composition
2
+
3
+ ## Context
4
+
5
+ Every plugin that judges directory operations registers the same request
6
+ hooks (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`,
7
+ `ldapdeleterequest`, `ldaprenamerequest`). Chained hooks run their
8
+ subscribers in order and stop at the first refusal, so two loaded
9
+ authorization plugins compose as an AND: the first to refuse answers. That
10
+ rule is sound when it is meant. It is a refusal nobody can explain when it is
11
+ not: `authzPerBranch` next to `authzDynamic` judges a machine token by a
12
+ branch configuration written for administrators and refuses a write the
13
+ token's ACLs grant.
14
+
15
+ ## Decision
16
+
17
+ A plugin with the `authz` role that registers LDAP hooks is a _judge_. It
18
+ judges the requests authenticated by the plugins named in `--authz-for`, or
19
+ everyone by default. A plugin that authenticates as well judges only its own
20
+ requests: its hooks read the credential it verified.
21
+
22
+ `assertAuthzComposition` runs once every configured plugin is loaded, and
23
+ again for each plugin registered later. It refuses:
24
+
25
+ - an `authz_for` naming a plugin that is not a loaded authenticator — the
26
+ plugin would judge nobody. A server-wide `--authz-for` that no loaded plugin
27
+ reads only warns, as does an `authz_for` on a plugin that also
28
+ authenticates;
29
+ - two judges whose populations overlap on an authenticator — the same one, or
30
+ one of them judging everyone — unless `--authz-combine` makes the AND
31
+ deliberate.
32
+
33
+ Two judges whose authenticators can run on the same prefix, without sharing an
34
+ authenticator name, are reported, not refused: asking for two credentials is
35
+ already a decision, and a request carrying both is judged by both. A judge on
36
+ `authzPerRoute` gates URLs and registers no LDAP hook, so it is not part of
37
+ this AND — route plus branch is a documented combination.
38
+
39
+ The request hooks of judges run before every other plugin's request hook and
40
+ `ldapsearchfilter` keeps the load order. All the LDAP hooks compose as an
41
+ AND, including the filters: two filters narrow the result twice.
42
+
43
+ ## Consequences
44
+
45
+ - A deployment with two branch models on the same population no longer starts
46
+ unless it says `--authz-combine`; the error names both plugins.
47
+ - Naming authenticators in `--authz-for` requires stable instance names, so
48
+ the configuration that scopes authentication and the one that scopes
49
+ authorization must agree.
50
+ - A plugin registered by an embedding host is checked before its routes and
51
+ hooks exist, so a refused plugin leaves nothing behind.
52
+ - Judges run first, in their load order. The chain stops at the first refusal,
53
+ so a judge after a refusing one does not run; the refusal is the answer.
54
+ No judge is skipped while the request is still allowed.
55
+ - A judge that also authenticates judges only its own requests; a request it
56
+ did not vouch for passes its hooks untouched, because the absence of its
57
+ credential is not a refusal it makes.
58
+
59
+ See `src/lib/authz/composition.ts` and `src/config/args.ts`.
@@ -0,0 +1,45 @@
1
+ # 0004 — Fail-closed request context
2
+
3
+ ## Context
4
+
5
+ An authorization judge decides from the request (`req.user`, its
6
+ authenticators, its path). But a directory operation is often made deep in a
7
+ plugin that never received the request: a provisioning step incremented from a
8
+ hook, a consistency repair, a route helper that searches before it writes.
9
+ Passing the request down every call is easy to forget, and a forgotten request
10
+ used to mean an operation nobody judged — a hole that is invisible until
11
+ someone writes through it.
12
+
13
+ ## Decision
14
+
15
+ The authentication dispatcher runs everything downstream of an authenticated
16
+ request inside `runForRequest(req, next)`, an `AsyncLocalStorage`. Every
17
+ directory operation defaults to `currentRequest()`, so a plugin that forgets
18
+ the request still gets the caller's identity and is judged as them. Operations
19
+ the server makes for itself run inside `outsideRequest()`, which `ldap.system`
20
+ uses: it is the explicit escape from judgement, not a default.
21
+
22
+ The log context is a second `AsyncLocalStorage`, kept apart: a line written as
23
+ the server, outside the request, still names the request that made the server
24
+ act. `outsideAnyRequest()` leaves both, for work that outlives a request (a
25
+ pooled connection, a process-wide client).
26
+
27
+ Hooks are the one place that deliberately leaves the context: the bus runs
28
+ every subscriber inside `outsideRequest()`, so a hook's own writes are the
29
+ server's and not the caller's. A hook that judges or acts for the caller has
30
+ the request in its arguments and names it.
31
+
32
+ ## Consequences
33
+
34
+ - A plugin that means to act as the server must say so with `ldap.system`;
35
+ forgetting the request fails closed instead of opening a hole.
36
+ - A test that calls a plugin's directory operations directly must wrap them in
37
+ `runForRequest` to reproduce what the server does, or get the unjudged
38
+ behaviour of `ldap.system`.
39
+ - Asynchronous work started inside the request inherits it, so a background
40
+ task that should not act as the caller has to leave the context first.
41
+ - Errors and logging inherit the request id, which makes a write traceable to
42
+ the request that caused it.
43
+
44
+ See `src/lib/requestContext.ts` and `src/lib/ldapActions.ts`
45
+ (`forRequest`, `system`, `currentRequest`).
@@ -0,0 +1,54 @@
1
+ # 0005 — Hook contract
2
+
3
+ ## Context
4
+
5
+ Plugins have to talk to each other without importing each other. A refusal
6
+ has to be able to stop a write; a notification has to be able to run after the
7
+ write, without holding the request open or turning a subscriber's failure into
8
+ the caller's failure. One kind of hook cannot do both.
9
+
10
+ ## Decision
11
+
12
+ There are two kinds, told apart by what a subscriber answers, and a third
13
+ notification mode:
14
+
15
+ - **Chained** (`chain(name, value)`). The value goes from one subscriber to
16
+ the next; the last answer is the result. A subscriber that throws stops the
17
+ chain and the error travels to the caller: a refusal is a refusal. Several
18
+ arguments are passed as one tuple.
19
+ - **Notified** (`notify(name, ...args)`). Subscribers run one after the other,
20
+ outside the request that caused the write, and are not awaited. A failing
21
+ subscriber is logged with its plugin and hook, counted in the hook failure
22
+ metrics, and the next one still runs.
23
+ - **Awaited notification** (`notifyAndWait`). As `notify`, but the caller waits
24
+ for the subscribers. A failure is still logged and swallowed.
25
+ - **Strict notification** (`notifyStrict`) for the rare failure that is part
26
+ of the answer. Every subscriber runs; the first failure is raised once they
27
+ all returned.
28
+
29
+ Every hook is declared in `src/hooks.ts` with its arguments and its answer. A
30
+ plugin subscribing to an undeclared hook does not compile, and `npm run
31
+ check:hooks` keeps the documentation in step. Notified hooks take their
32
+ arguments one by one; chained hooks take one value, a tuple when there are
33
+ several.
34
+
35
+ The request hooks of authorization plugins run first, in load order. The
36
+ other hooks run in plugin load order.
37
+
38
+ ## Consequences
39
+
40
+ - A notified hook cannot refuse a write; a plugin that needs to refuse
41
+ subscribes to the request hook instead.
42
+ - A chained subscriber must answer the value it was given, changed or not: an
43
+ `undefined` answer becomes the next subscriber's input.
44
+ - A subscriber of a notified hook written `([dn, entry], ctx) => …` compiles
45
+ without annotation — its parameter takes the declared type, a `string`, and
46
+ a string can be destructured — and then reads the first character of the
47
+ DN. The documentation repeats the shape of each hook for this reason.
48
+ - A "done" hook fires once the operation is over, including a modify that had
49
+ nothing to apply; a write refused by a request hook or by the directory
50
+ never reaches it. The "end" hook fires all the same, once the done
51
+ subscribers returned. Keying state by the operation number is what tells
52
+ two writes of one DN apart.
53
+
54
+ See [hooks](../../plugin-development/hooks.md) and `src/lib/hookBus.ts`.
@@ -0,0 +1,49 @@
1
+ # 0006 — Search cache
2
+
3
+ ## Context
4
+
5
+ Reading one entry by DN is the most frequent operation a route makes: before a
6
+ write, before an authorization decision, to render a page. A directory round
7
+ trip per read is measurable, and a cache in front of it is easy to add and
8
+ hard to keep honest. Serving a stale entry after a write is worse than the
9
+ round trip.
10
+
11
+ ## Decision
12
+
13
+ `SearchCache` caches non-paged, base-scope searches — single-entry lookups by
14
+ DN, with whatever filter — keyed by base, scope, filter, attributes, options
15
+ and values mode, in an LRU bounded by `--ldap-cache-max`. It is **off by
16
+ default** (`--ldap-cache-ttl=0`): no deployment has run with it on, and
17
+ turning it on for everyone would be a silent behaviour change.
18
+
19
+ `ldapActions` drops, around every write it makes through this process:
20
+
21
+ - the reads of the entry written and of its subtree (a rename or a move
22
+ re-parents a whole branch);
23
+ - the `memberOf` of the members a group change adds or removes;
24
+ - the reads of entries naming the entry renamed or deleted, because the
25
+ directory's `memberof` and `refint` overlays rewrite them.
26
+
27
+ Entries are dropped before the directory call as well as after it, and a
28
+ generation counter stops a read that started before the write from storing
29
+ its stale answer afterwards. DNs are compared the way the directory does:
30
+ case, spacing, escapes, aliases and multi-valued RDN order do not matter.
31
+
32
+ Writes made elsewhere — another replica, an LSC synchronisation, a hand-run
33
+ `ldapmodify` — do not invalidate anything. Each process caches on its own.
34
+
35
+ ## Consequences
36
+
37
+ - Enable the cache only when this service is the only writer, or when serving
38
+ an entry up to `--ldap-cache-ttl` seconds old is acceptable.
39
+ - A subtree or one-level read, or a paged search, is never cached; the cache
40
+ does not accelerate listings. A base-scope read with a filter is cached,
41
+ keyed by that filter.
42
+ - The cache does not lift a miss: a read of an absent DN throws and caches
43
+ nothing.
44
+ - The invalidation rules follow the overlays this project documents
45
+ (`memberof`, `refint`); a deployment with an overlay that rewrites other
46
+ entries must accept a miss or leave the cache off.
47
+
48
+ See [configuration](../../usage/configuration.md#the-search-cache) and
49
+ `src/lib/ldap/searchCache.ts`.
@@ -0,0 +1,69 @@
1
+ # 0007 — Trash and tombstone
2
+
3
+ ## Context
4
+
5
+ Deleting an entry destroys the identifiers it held: a mail address, a login, a
6
+ DN. An administrator who deletes the wrong account cannot undo it, and a
7
+ deleted account's address can be taken by a new one immediately — which, for a
8
+ mail system, means the new account receives the old one's mail. The directory
9
+ has no undelete of its own.
10
+
11
+ ## Decision
12
+
13
+ Two plugins answer a delete at the `ldapdeleterequest` hook, and a deployment
14
+ picks one per branch:
15
+
16
+ - **`core/ldap/trash`** moves the entry, by an atomic `modifyDN`, to a trash
17
+ branch under `--trash-base`, overwriting an earlier entry with the same RDN
18
+ there; unless `--trash-add-metadata=false`, it then replaces `description`
19
+ with the deletion date and the original DN, a failure there being logged
20
+ and not fatal. The native delete is cancelled. `--trash-watched-bases`
21
+ selects the branches; an empty list watches every branch.
22
+ - **`core/twake/tombstone`** keeps the entry in place: one modify sets the
23
+ deleted attribute and the lock, the deletion date and the reason when their
24
+ attributes are configured, and removes the attributes the deployment names.
25
+ The identifier is reserved until an operator erases the tombstone, once the
26
+ deletion is old enough or immediately when forced. Erasing removes the entry
27
+ and its group memberships and announces nothing.
28
+
29
+ The tombstone moves itself last in the `ldapdeleterequest` and
30
+ `ldapaddrequest` chains (`hookBus.toLast`), so every plugin that may refuse
31
+ has judged. The trash does not: it runs in load order, after the authorization
32
+ judges but possibly before another plugin that can refuse a delete
33
+ (`organizations`, `enterpriseRules`, `james`). A plugin that refuses a delete
34
+ and loads after the trash refuses one that has already been moved. The
35
+ tombstone is written by the server, not as the caller: the caller's delete
36
+ right is what was judged.
37
+
38
+ SCIM treats a tombstone as gone: its reads carry a not-deleted filter, so
39
+ reads answer 404 and lists omit it; a create with the same identity erases
40
+ the tombstone and creates a new entry. A non-SCIM delete of an existing
41
+ tombstone writes nothing and fires `twakedeletionreplay`, so lifecycle events
42
+ can replay a lost deletion. A SCIM DELETE never gets there: its existence
43
+ check hides the tombstone and answers 404.
44
+
45
+ The server refuses to start when the tombstone is loaded beside the trash and
46
+ any watched branch overlaps a `--twake-tombstone-dn` pattern: the trash would
47
+ move the entry before the tombstone saw the delete, and no tombstone would be
48
+ written. An empty `--trash-watched-bases` watches every branch, so it overlaps
49
+ any pattern and refuses too.
50
+
51
+ ## Consequences
52
+
53
+ - A delete is no longer a delete. Code that lists a branch sees what the
54
+ trash or the tombstone left there, filtered by the plugin that owns the
55
+ branch.
56
+ - Trash and tombstone cannot watch the same branch; the startup check names
57
+ the overlap.
58
+ - A deployment that loads the trash must load it before the plugins that can
59
+ refuse a delete, or accept that a refusal can come after the entry moved.
60
+ - A tombstone cannot be restored: clearing its deleted attribute by hand is
61
+ not a supported operation.
62
+ - Trash moves the entry, so its DN changes and references to it are not
63
+ rewritten by the move beyond what the directory's `refint` overlay does.
64
+ - Tombstones are erased by an explicit operator action, so a retention policy
65
+ has to run it.
66
+
67
+ See [tombstone](../../usage/plugins/integrations/tombstone.md), the plugin
68
+ list in [plugins](../../usage/plugins/README.md), and
69
+ `src/plugins/ldap/trash.ts`, `src/plugins/twake/tombstone.ts`.
@@ -0,0 +1,58 @@
1
+ # 0008 — Server views for plugins with overrides
2
+
3
+ ## Context
4
+
5
+ The same plugin module can be loaded several times with different settings:
6
+ `--plugin 'core/auth/token:admins:{…}'` and
7
+ `--plugin 'core/auth/token:users:{…}'`. The plugin constructor receives the
8
+ server, and reads its options from `this.server.config` and `this.config`.
9
+ Those instances must see their own configuration while acting on the one
10
+ server: the same Express app, the same hooks, the same LDAP connection and
11
+ the same plugin registry. A second `DM` would be a second server.
12
+
13
+ ## Decision
14
+
15
+ `DM.withConfig(config)` returns a view of the server built with
16
+ `Object.create(Object.getPrototypeOf(this))` and `Object.assign(…, this,
17
+ { config })`. The own properties that hold objects — the app, the hooks, the
18
+ LDAP actions, the plugin registry, the authenticators — are shared by
19
+ reference. The configuration is `{ ...this.config, ...overrides }`, a shallow
20
+ copy: overridden top-level keys are the plugin's alone, nested objects and
21
+ arrays are still shared with the server. The loader constructs a plugin with
22
+ overrides on such a view.
23
+
24
+ A primitive field would be copied by value into every view, so state the views
25
+ must share lives in an object: the operation counter (`_operations`, read
26
+ through the `operationSequence` accessor) and the composition flag
27
+ (`_compositionState`), as the dispatcher's own state does (`_authState`). A
28
+ view created while the plugins load therefore sees the composition once the
29
+ server has checked it.
30
+
31
+ The copy is built on the prototype rather than by spreading `{...this}`,
32
+ which copies own properties and leaves the methods behind: a plugin calling
33
+ `this.server.anything()` would throw, and only when configured with
34
+ overrides.
35
+
36
+ ## Consequences
37
+
38
+ - A plugin loaded with overrides cannot change the server's configuration for
39
+ anyone else; mutating `this.config` affects its view alone, at the top
40
+ level.
41
+ - Primitive fields are copied by value, not shared: what must be shared is
42
+ held in an object, as the operation counter and the composition flag are.
43
+ A plugin registered after `ready` through a view created while loading is
44
+ therefore checked against the composition like any other (before [#625] and
45
+ [#626], the counter and the flag were primitives, and neither held).
46
+ - An own property added to the server after the view was created is not
47
+ visible to that view. State that must be shared has to live in an object
48
+ that already exists when the view is made.
49
+ - A plugin that closes over `this.server` keeps the view for its lifetime,
50
+ which is the point: its options stay its own.
51
+ - Tests may call `withConfig` to exercise option-dependent behaviour without
52
+ building a second server.
53
+
54
+ See `src/bin/index.ts` (`withConfig`, `importPlugin`) and
55
+ [plugin options](../../plugin-development/options.md).
56
+
57
+ [#625]: https://github.com/linagora/ldap-rest/issues/625
58
+ [#626]: https://github.com/linagora/ldap-rest/issues/626
@@ -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,119 @@
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
+ - `etc/api/*.api.md` are the API Extractor reports of the five entry points
35
+ #595 covers first — `ldap-rest`, `./plugin`, `./hooks`, `./ldapactions` and
36
+ `./expressformatedresponses` — one file per sub-path. Regenerate them with
37
+ `npm run snapshot:api`; `npm run check:api`, part of `npm run check`, fails
38
+ when one is stale. A report lists what the entry exports and the shapes of
39
+ the types those exports reference, including the types the entry does not
40
+ export itself, so a change to `RouteDefinition` or `OptionSet` shows up even
41
+ though a plugin cannot name them. The 66 `./plugin-*` entries come with the
42
+ modularization, see [the plan](modularization-plan.md).
43
+
44
+ The snapshot records what the parser reads. A narrowed `choices` list, a
45
+ raised `min` or a lowered `max` is checked when a plugin reads its options,
46
+ not by the snapshot, and is reviewed by hand, as is a custom error `code`.
47
+
48
+ ## The pull request gate
49
+
50
+ The `contracts` job of `.github/workflows/contracts.yml` generates the
51
+ snapshots from the pull request and compares them with the base branch's
52
+ committed ones. It reports:
53
+
54
+ - additive changes — a new option, route or field — and lets them through;
55
+ - non-additive changes, and fails unless the pull request carries the
56
+ `breaking` label.
57
+
58
+ A change is non-additive when it removes or narrows what was published:
59
+ a removed option or environment variable, a changed type, default, bound,
60
+ `const`, enum or required flag, a request value that becomes an enum, a
61
+ request object that stops accepting additional properties, a removed route,
62
+ method, parameter, response status, response header or field, a request field
63
+ that becomes required, a response that may now be null, changed security
64
+ requirements, a removed union variant, a value an alternative of the type
65
+ closes with an enum or a `const` where the type used to accept any, an
66
+ untyped request value that becomes a union, an `allOf` that adds a
67
+ constraint, or a removed declaration in `etc/api/` that a plugin can name —
68
+ the exported one, or a member of an exported namespace. Renaming a type only
69
+ reachable through a signature is not a break: nobody can write its name, and
70
+ the declarations that use it are reported as changed. A changed declaration is
71
+ printed for review and does not fail the job yet: telling an added optional
72
+ member from a narrowed union needs more than a line diff, and comes with the
73
+ `./plugin-*` reports. Renaming an option is a deprecation, not a removal, as
74
+ long as the old CLI name and environment variable stay in its `aliases` and
75
+ `envAliases`. Adding a union variant, a field or an optional parameter is
76
+ additive, and so is a request union with an alternative that still accepts
77
+ everything the base did — in a response the client reads every alternative,
78
+ so a field the others drop is still a removal. A `oneOf` is the exception:
79
+ it refuses a value two alternatives match, so an alternative as wide as the
80
+ base narrows the others away, and the alternatives are not told apart from
81
+ one another — a disjoint `oneOf` therefore asks for the label too.
82
+ Documentation-only edits, such as a summary or description, are ignored.
83
+
84
+ The job runs the scripts from the pull request, so a change to them is
85
+ reviewed like any other code, as `.github/CODEOWNERS` requires.
86
+
87
+ The `breaking` label records that the maintainers accepted the break, and the
88
+ `CHANGELOG.md` section must say so: the release gate below reads the section,
89
+ not the label, and refuses to ship the break as a minor or patch release. The
90
+ workflow listens to label events, so adding the label runs the job again; it
91
+ has its own concurrency group, so that event does not cancel the test matrix.
92
+
93
+ To try the comparison locally against your base branch:
94
+
95
+ ```bash
96
+ npx tsx scripts/contracts.ts --base origin/master
97
+ ```
98
+
99
+ ## The release gate
100
+
101
+ `.github/workflows/release.yml` runs `scripts/check-release.ts` on the tag. A
102
+ non-major tag whose `CHANGELOG.md` section lists `### Breaking Changes` is
103
+ refused. For a 0.x version, a minor release counts as major, as semantic
104
+ versioning lets it break. When the tag has no section of its own, the gate
105
+ reads `## Unreleased`, so a tag cut before the section was renamed is still
106
+ checked; with neither section it fails. When the CHANGELOG lists no earlier
107
+ release, only a first `X.0.0` counts as major, so a trimmed file cannot open
108
+ the gate.
109
+
110
+ ## Deprecating instead of breaking
111
+
112
+ The window and the rules are in
113
+ [CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract).
114
+ The mechanics of an option alias are in
115
+ [plugin options](../plugin-development/options.md#deprecating-an-option): the
116
+ old CLI name and environment variable go in `aliases` and `envAliases`, and
117
+ the server warns at startup while it still reads them. An array option's
118
+ plural form has no alias slot: renaming it is a breaking change, so it waits
119
+ for a major release.