ldap-rest 2.0.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.
package/README.md CHANGED
@@ -81,7 +81,7 @@ See **[Configuration Guide](./docs/usage/configuration.md)** for all CLI options
81
81
 
82
82
  ## Documentation
83
83
 
84
- Documentation is organized into 3 categories:
84
+ Documentation is organized into 4 categories:
85
85
 
86
86
  ### [Usage](./docs/usage/README.md)
87
87
 
@@ -110,6 +110,13 @@ Integrate LDAP-Rest into your web applications.
110
110
  - **[Examples](./docs/client-development/examples/README.md)** - React, Vue.js, Vanilla JS
111
111
  - **[Browser Demos](./examples/web/)** - Interactive demos (TOTP client, LDAP tree viewer)
112
112
 
113
+ ### [Architecture](./docs/architecture/README.md)
114
+
115
+ How the server is put together and why.
116
+
117
+ - **[Overview](./docs/architecture/README.md)** - Module map, life of a request, life of a write
118
+ - **[Decision records](./docs/architecture/adr/)** - The decisions the code embodies
119
+
113
120
  ## Copyright and license
114
121
 
115
122
  [![Powered by LINAGORA](./docs/linagora.png)](https://linagora.com)
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as t,dirname as i}from"path";import s from"express";import r from"body-parser";import{ConfigParser as o}from"../lib/parseConfig.js";import{unknownEnvironmentOptions as n}from"../config/options.js";import{keepRawBody as a}from"../lib/rawBody.js";import h from"../config/args.js";import l from"../lib/ldapActions.js";import{buildLogger as d}from"../logger/winston.js";import{setLogger as u,setDebug as c,errorResponse as g,messageOf as p}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as f}from"../lib/auth/base.js";import{assertAuthzComposition as m,assertEnforceable as w,LDAP_JUDGING_HOOKS as b}from"../lib/authz/composition.js";import{registerAttributeTypes as v}from"../lib/utils.js";export{assertRfc4514Escapes,assertedRdnValue,asyncHandler,canonicalDn,escapeDnValue,escapeLdapFilter,escapeRegex,fillPattern,getCompiledRegex,getParentDn,getRdn,hasOnlyRfc4514Escapes,isChildOf,isDirectChildOf,isDnInBranch,isDnSyntax,isDummyMemberDn,knownAttributeName,movedToTrash,namesByDn,normalizeDn,organizationLink,parseDirectoryDate,parseDn,rdnTypes,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import{BadRequestError as y,errorChain as $}from"../lib/errors.js";export{AuthzRefusedError,BadGatewayError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import P,{recordHookOwner as j}from"../lib/hookBus.js";export{hookOwner,runAsHookOwner}from"../lib/hookBus.js";import{runWithLogContext as k,runForRequest as O}from"../lib/requestContext.js";import{requestId as S}from"../lib/changeContext.js";import{mountHealth as M,HEALTH_PATHS as x}from"../lib/health.js";import{httpRequestDuration as D,routeTemplate as C,secondsSince as E,unhandledRejections as _}from"../lib/metrics.js";import{registerProcessShutdown as A,shutdownTimeout as R}from"../lib/shutdown.js";import T from"../plugins/priority.json.js";const rejectEmptyMediaType=(e,t,i)=>{const s=e.headers["content-type"];(e=>void 0!==e.headers["transfer-encoding"]||!Number.isNaN(Number(e.headers["content-length"])))(e)&&"string"==typeof s&&""===s.split(";",1)[0].trim()?i(new y("Content-Type names no media type")):i()},stackChain=e=>{const t=$(e).map(e=>e.stack).filter(e=>Boolean(e));return t.length?t.join("\nCaused by: "):void 0},L=b.filter(e=>"ldapsearchfilter"!==e);class DM{app;config;ready;get server(){return this.life.server}set server(e){this.life.server=e}hooks={};hookBus=new P(this.hooks);loadedPlugins={};services=new Map;serviceOwners=new Map;ldap;operationSequence;logger;authenticators=[];_authState={dispatcherMounted:!1};_errorMiddlewareSetup=!1;bodyParsers=s.Router();_composed=!1;get composed(){return this._composed}life={unhandledRejections:0};inFlight=new Set;get stopping(){return void 0!==this.life.shutdown}get unhandledRejections(){return this.life.unhandledRejections}_errorMiddleware;pluginLoads=new Map;unknownCliOptions;deprecationWarnings;authzHooks=new WeakSet;constructor(){const e=new o(h);this.config=e.parse(),this.unknownCliOptions=e.unknownOptions,this.deprecationWarnings=e.deprecationWarnings,this.app=s(),this.app.disable("x-powered-by"),this.app.use((e,t,i)=>{const s=process.hrtime.bigint();t.setHeader("X-Content-Type-Options","nosniff"),this.stopping&&t.setHeader("Connection","close"),this.inFlight.add(t),t.once("finish",()=>{D.observe({method:e.method,route:C(e),status:String(t.statusCode)},E(s)),this.stopping&&this.server?.closeIdleConnections()}),t.once("close",()=>this.inFlight.delete(t)),i()}),this.app.use((e,t,i)=>{const s=S(e);t.setHeader("X-Request-Id",s),k({requestId:s},i)}),this.app.use(rejectEmptyMediaType),this.app.use(this.bodyParsers),this.app.use(r.json({verify:a})),this.app.use(r.urlencoded({extended:!0,verify:a})),M(this.app,this),this.logger=d(this.config);for(const e of this.deprecationWarnings)this.logger.warn(e);this.ldap=new l(this),u(this.logger),c(this.config.debug),this.operationSequence=0;const t=[];if(this.config.plugin){const e=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=this.logger,r=(async()=>{for(const e of T){const t=this.resolveModule(DM.moduleOf(e)),r=i.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==r.length){i=i.filter(e=>!r.includes(e));for(const e of r)s.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();t.push(r),t.push(r.then(async()=>{const e=i.map(e=>(s.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),e&&t.push(Promise.all(t).then(()=>this.loadPlugin(e)))}this.ready=new Promise((e,i)=>{t.length>0?Promise.all(t).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await m(this),w(this),await this.learnAttributeTypes(),this._composed=!0,this.reportUnknownOptions(),this.afterLoad(),e()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._composed=!0,e())})}async learnAttributeTypes(){if(Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz")))try{v((await this.ldap.schemaIndex()).schema.attributeTypes)}catch(e){this.logger.warn(`Could not read the directory schema, DNs are compared with the usual attribute type names only: ${String(e)}`)}}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}reportUnknownOptions(){const e=Object.values(this.loadedPlugins).map(e=>e.constructor.optionDefinitions??{}),t=new Set(e.flatMap(e=>Object.entries(e).flatMap(([e,t])=>[e,...(t.aliases??[]).map(e=>e.replace(/^--?/,"").replace(/-/g,"_"))]))),i=[...this.unknownCliOptions.filter(e=>!t.has(e.replace(/^--?/,"").replace(/-/g,"_"))).map(e=>`option ${e}`),...n(h,process.env,e).map(e=>`environment variable ${e}`)];if(0===i.length)return;const s=`Unknown configuration: ${i.join(", ")} — no declared option reads it`;if(this.config.strict_options)throw new Error(s);this.logger.warn(s)}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,i)=>{this.dispatchAuth(e,t,i)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,i){let s=-1,r=[];for(const t of this.authenticators)for(const i of t.pathPrefixes)f(i,e.path)&&(i.length>s?(s=i.length,r=[t]):i.length!==s||r.includes(t)||r.push(t));if(s<0&&(r=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===r.length)return i();const finish=s=>{if(s>=r.length)return O(e,i);const o=r[s];if(!o.afterChain)return finish(s+1);o.afterChain(e,t,()=>finish(s+1))},o=[];e.authenticators=o;const run=i=>{if(i>=r.length)return finish(0);const s=r[i];s.authenticate(e,t,()=>{s.vouchedFor(e)&&o.push(s.name),run(i+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const i of e){if(!i.pathPrefixes||0===i.pathPrefixes.length)return[];t.push(...i.pathPrefixes)}const i=this.routerStack(),s=new Set;for(const e of i||[]){const t=e.route?.path;"string"==typeof t?s.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&s.add(e))}const r=[...s].filter(e=>!x.includes(e)&&!t.some(t=>e===t||e.startsWith(`${t}/`))),o=Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz"));return r.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication`+(o?" nor authorization":"")+`: ${r.sort().join(", ")}`),r.sort()}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.routerStack();if(e){const t=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==t&&e.splice(t,1)}}this._errorMiddleware=(e,t,i,s)=>{const{status:r,body:o}=g(e,this.config.debug);if(r<500)return this.logger.warn(`Client error ${r} in request ${t.method} ${t.path}: ${o.error}`),void(i.headersSent||i.status(r).json(o));this.logger.error(`Server error ${r} in request ${t.method} ${t.path}: ${p(e)}`,{stack:stackChain(e),url:t.url,method:t.method}),i.headersSent||i.status(r).json(o)},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}exitOnCrash(){process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:stackChain(e)}),setTimeout(()=>process.exit(1),this.config.shutdown_timeout),this.shutdown().finally(()=>process.exit(1))}),process.on("unhandledRejection",e=>{this.life.unhandledRejections++,_.inc(),this.logger.error(`Unhandled promise rejection: ${e instanceof Error?e.message:String(e)}`,{stack:stackChain(e)})})}run(){return new Promise((e,t)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),t(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}shutdown(){return this.life.shutdown??=this.letGo(),this.life.shutdown}async letGo(){const e=this.config.shutdown_timeout,t=Date.now(),i=Math.max(1e3,e-5e3);this.logger.notice(`Shutting down, ${i} ms for what is in flight`);const s=this.server;if(s){const e=new Promise(e=>s.close(()=>e()));s.closeIdleConnections();for(const e of this.inFlight)e.headersSent||e.setHeader("Connection","close");await DM.within(e,i),s.closeAllConnections()}const r=new Map;for(const[e,t]of Object.entries(this.loadedPlugins))r.set(e,Promise.resolve().then(()=>t.close?.()).catch(t=>{this.logger.warn(`${e}: close failed: ${t instanceof Error?t.message:String(t)}`)}).finally(()=>r.delete(e)));const o=Promise.all(r.values()).then(()=>this.ldap.close().catch(()=>{})),n=Math.max(1e3,e-(Date.now()-t)-1e3);await DM.within(o,n)||(this.logger.warn(`Shut down with a close still pending: ${[...r.keys()].join(", ")||"the directory connections"}`),this.ldap.close().catch(()=>{})),this.logger.notice("Shut down")}static async within(e,t){let i;const s=new Promise(e=>{i=setTimeout(()=>e(!1),t)});try{return await Promise.race([e.then(()=>!0),s])}finally{clearTimeout(i)}}shutdownOnSignals(){R.ms=this.config.shutdown_timeout,A(()=>this.shutdown())}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(s){return s.startsWith("core/")?s.replace("core/",t(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):s.startsWith(".")?e(new URL(s,import.meta.url)):s}loadPlugin(e){let t,i;if(/:/.test(e)){let s=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(s)){t=s.substring(0,s.indexOf(":")),t||(t=void 0),s=s.substring(s.indexOf(":")+1);try{i=JSON.parse(s),"object"!=typeof i?(this.logger.error(`Overrides for plugin ${e} are not valid: ${s}`),i=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${s}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${s}`),i=void 0}}else t=s,t||(t=void 0)}else t=void 0;const s=i?void 0:`${this.resolveModule(e)}\0${t??""}`,r=void 0===s?void 0:this.pluginLoads.get(s);if(r)return this.logger.debug(`Plugin ${e} already loaded, skipping`),r;const o=this.importPlugin(e,t,i);if(void 0!==s){this.pluginLoads.set(s,o);const forget=()=>{this.pluginLoads.get(s)===o&&this.pluginLoads.delete(s)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,i){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((s,r)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),i){const e={...this.config,...i};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return r(new Error(`Unable to load ${e}`));s(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>r(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await m(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,i){if(t.name||(t.name=e),i&&(t.name=i),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const i=t.hooks[e];if(void 0===i)continue;if("function"!=typeof i)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);const s=e;if(j(i,t.name,e),t.roles?.includes("authz")&&L.includes(s)){const e=this.hookBus.subscribers(s),t=e.findIndex(e=>!this.authzHooks.has(e));this.hookBus.insert(s,i,t<0?e.length:t),this.authzHooks.add(i)}else this.hookBus.subscribe(s,i)}return this.loadedPlugins[t.name]=t,this.registerServices(t),!0}registerServices(e){for(const t of e.provides??[]){const i=this.serviceOwners.get(t.token);void 0===i?(this.services.set(t.token,t.service),this.serviceOwners.set(t.token,e.name)):this.logger.warn(`Plugin ${e.name}: service '${t.token.name}' is already provided by ${i}; this one is ignored`)}}}export{y as BadRequestError,DM,j as recordHookOwner,v as registerAttributeTypes};
1
+ import{fileURLToPath as e}from"url";import{join as t,dirname as s}from"path";import i from"express";import o from"body-parser";import{ConfigParser as r}from"../lib/parseConfig.js";import{unknownEnvironmentOptions as n}from"../config/options.js";import{keepRawBody as a}from"../lib/rawBody.js";import h from"../config/args.js";import l from"../lib/ldapActions.js";import{buildLogger as c}from"../logger/winston.js";import{setLogger as u,setDebug as d,errorResponse as p,messageOf as g}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as f}from"../lib/auth/base.js";import{assertAuthzComposition as m,assertEnforceable as w,LDAP_JUDGING_HOOKS as b}from"../lib/authz/composition.js";import{registerAttributeTypes as v}from"../lib/utils.js";export{assertRfc4514Escapes,assertedRdnValue,asyncHandler,canonicalDn,escapeDnValue,escapeLdapFilter,escapeRegex,fillPattern,getCompiledRegex,getParentDn,getRdn,hasOnlyRfc4514Escapes,isChildOf,isDirectChildOf,isDnInBranch,isDnSyntax,isDummyMemberDn,knownAttributeName,movedToTrash,namesByDn,normalizeDn,organizationLink,parseDirectoryDate,parseDn,rdnTypes,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import{BadRequestError as y,errorChain as $}from"../lib/errors.js";export{AuthzRefusedError,BadGatewayError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import P,{recordHookOwner as j}from"../lib/hookBus.js";export{hookOwner,runAsHookOwner}from"../lib/hookBus.js";import{runWithLogContext as k,runForRequest as S}from"../lib/requestContext.js";import{requestId as O}from"../lib/changeContext.js";import{mountHealth as x,HEALTH_PATHS as D}from"../lib/health.js";import{httpRequestDuration as C,routeTemplate as E,secondsSince as M,unhandledRejections as A}from"../lib/metrics.js";import{registerProcessShutdown as _,shutdownTimeout as R}from"../lib/shutdown.js";import T from"../plugins/priority.json.js";const rejectEmptyMediaType=(e,t,s)=>{const i=e.headers["content-type"];(e=>void 0!==e.headers["transfer-encoding"]||!Number.isNaN(Number(e.headers["content-length"])))(e)&&"string"==typeof i&&""===i.split(";",1)[0].trim()?s(new y("Content-Type names no media type")):s()},stackChain=e=>{const t=$(e).map(e=>e.stack).filter(e=>Boolean(e));return t.length?t.join("\nCaused by: "):void 0},L=b.filter(e=>"ldapsearchfilter"!==e);class DM{app;config;ready;get server(){return this.life.server}set server(e){this.life.server=e}hooks={};hookBus=new P(this.hooks);loadedPlugins={};services=new Map;serviceOwners=new Map;ldap;_operations={sequence:0};get operationSequence(){return this._operations.sequence}set operationSequence(e){this._operations.sequence=e}logger;authenticators=[];_authState={dispatcherMounted:!1};_errorState={};bodyParsers=i.Router();_compositionState={composed:!1};get composed(){return this._compositionState.composed}life={unhandledRejections:0};inFlight=new Set;get stopping(){return void 0!==this.life.shutdown}get unhandledRejections(){return this.life.unhandledRejections}pluginLoads=new Map;unknownCliOptions;deprecationWarnings;authzHooks=new WeakSet;constructor(){const e=new r(h);this.config=e.parse(),this.unknownCliOptions=e.unknownOptions,this.deprecationWarnings=e.deprecationWarnings,this.app=i(),this.app.disable("x-powered-by"),this.app.use((e,t,s)=>{const i=process.hrtime.bigint();t.setHeader("X-Content-Type-Options","nosniff"),this.stopping&&t.setHeader("Connection","close"),this.inFlight.add(t),t.once("finish",()=>{C.observe({method:e.method,route:E(e),status:String(t.statusCode)},M(i)),this.stopping&&this.server?.closeIdleConnections()}),t.once("close",()=>this.inFlight.delete(t)),s()}),this.app.use((e,t,s)=>{const i=O(e);t.setHeader("X-Request-Id",i),k({requestId:i},s)}),this.app.use(rejectEmptyMediaType),this.app.use(this.bodyParsers),this.app.use(o.json({verify:a})),this.app.use(o.urlencoded({extended:!0,verify:a})),x(this.app,this),this.logger=c(this.config);for(const e of this.deprecationWarnings)this.logger.warn(e);this.ldap=new l(this),u(this.logger),d(this.config.debug);const t=[];if(this.config.plugin){const e=this.config.plugin.find(e=>e.includes("configApi"));let s=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,o=(async()=>{for(const e of T){const t=this.resolveModule(DM.moduleOf(e)),o=s.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==o.length){s=s.filter(e=>!o.includes(e));for(const e of o)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();t.push(o),t.push(o.then(async()=>{const e=s.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),e&&t.push(Promise.all(t).then(()=>this.loadPlugin(e)))}this.ready=new Promise((e,s)=>{t.length>0?Promise.all(t).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await m(this),w(this),await this.learnAttributeTypes(),this._compositionState.composed=!0,this.reportUnknownOptions(),this.afterLoad(),e()}).catch(e=>s(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._compositionState.composed=!0,e())})}async learnAttributeTypes(){if(Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz")))try{v((await this.ldap.schemaIndex()).schema.attributeTypes)}catch(e){this.logger.warn(`Could not read the directory schema, DNs are compared with the usual attribute type names only: ${String(e)}`)}}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}reportUnknownOptions(){const e=Object.values(this.loadedPlugins).map(e=>e.constructor.optionDefinitions??{}),t=new Set(e.flatMap(e=>Object.entries(e).flatMap(([e,t])=>[e,...(t.aliases??[]).map(e=>e.replace(/^--?/,"").replace(/-/g,"_"))]))),s=[...this.unknownCliOptions.filter(e=>!t.has(e.replace(/^--?/,"").replace(/-/g,"_"))).map(e=>`option ${e}`),...n(h,process.env,e).map(e=>`environment variable ${e}`)];if(0===s.length)return;const i=`Unknown configuration: ${s.join(", ")} — no declared option reads it`;if(this.config.strict_options)throw new Error(i);this.logger.warn(i)}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,s)=>{this.dispatchAuth(e,t,s)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,s){let i=-1,o=[];for(const t of this.authenticators)for(const s of t.pathPrefixes)f(s,e.path)&&(s.length>i?(i=s.length,o=[t]):s.length!==i||o.includes(t)||o.push(t));if(i<0&&(o=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===o.length)return s();const finish=i=>{if(i>=o.length)return S(e,s);const r=o[i];if(!r.afterChain)return finish(i+1);r.afterChain(e,t,()=>finish(i+1))},r=[];e.authenticators=r;const run=s=>{if(s>=o.length)return finish(0);const i=o[s];i.authenticate(e,t,()=>{i.vouchedFor(e)&&r.push(i.name),run(s+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const s of e){if(!s.pathPrefixes||0===s.pathPrefixes.length)return[];t.push(...s.pathPrefixes)}const s=this.routerStack(),i=new Set;for(const e of s||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const o=[...i].filter(e=>!D.includes(e)&&!t.some(t=>e===t||e.startsWith(`${t}/`))),r=Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz"));return o.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication`+(r?" nor authorization":"")+`: ${o.sort().join(", ")}`),o.sort()}setupErrorMiddleware(){const e=this._errorState.handler;if(e){const t=this.routerStack();if(t){const s=t.findIndex(t=>t.handle===e);-1!==s&&t.splice(s,1)}}const handler=(e,t,s,i)=>{const{status:o,body:r}=p(e,this.config.debug);if(o<500)return this.logger.warn(`Client error ${o} in request ${t.method} ${t.path}: ${r.error}`),void(s.headersSent||s.status(o).json(r));this.logger.error(`Server error ${o} in request ${t.method} ${t.path}: ${g(e)}`,{stack:stackChain(e),url:t.url,method:t.method}),s.headersSent||s.status(o).json(r)};this._errorState.handler=handler,this.app.use(handler)}exitOnCrash(){process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:stackChain(e)}),setTimeout(()=>process.exit(1),this.config.shutdown_timeout),this.shutdown().finally(()=>process.exit(1))}),process.on("unhandledRejection",e=>{this.life.unhandledRejections++,A.inc(),this.logger.error(`Unhandled promise rejection: ${e instanceof Error?e.message:String(e)}`,{stack:stackChain(e)})})}run(){return new Promise((e,t)=>{this.server=this.app.listen(this.config.port,s=>{s?(this.logger.error(`Error starting server: ${s}`),t(s)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}shutdown(){return this.life.shutdown??=this.letGo(),this.life.shutdown}async letGo(){const e=this.config.shutdown_timeout,t=Date.now(),s=Math.max(1e3,e-5e3);this.logger.notice(`Shutting down, ${s} ms for what is in flight`);const i=this.server;if(i){const e=new Promise(e=>i.close(()=>e()));i.closeIdleConnections();for(const e of this.inFlight)e.headersSent||e.setHeader("Connection","close");await DM.within(e,s),i.closeAllConnections()}const o=new Map;for(const[e,t]of Object.entries(this.loadedPlugins))o.set(e,Promise.resolve().then(()=>t.close?.()).catch(t=>{this.logger.warn(`${e}: close failed: ${t instanceof Error?t.message:String(t)}`)}).finally(()=>o.delete(e)));const r=Promise.all(o.values()).then(()=>this.ldap.close().catch(()=>{})),n=Math.max(1e3,e-(Date.now()-t)-1e3);await DM.within(r,n)||(this.logger.warn(`Shut down with a close still pending: ${[...o.keys()].join(", ")||"the directory connections"}`),this.ldap.close().catch(()=>{})),this.logger.notice("Shut down")}static async within(e,t){let s;const i=new Promise(e=>{s=setTimeout(()=>e(!1),t)});try{return await Promise.race([e.then(()=>!0),i])}finally{clearTimeout(s)}}shutdownOnSignals(){R.ms=this.config.shutdown_timeout,_(()=>this.shutdown())}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(i){return i.startsWith("core/")?i.replace("core/",t(s(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,s;if(/:/.test(e)){let i=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(i)){t=i.substring(0,i.indexOf(":")),t||(t=void 0),i=i.substring(i.indexOf(":")+1);try{s=JSON.parse(i),"object"!=typeof s?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),s=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${i}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${i}`),s=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=s?void 0:`${this.resolveModule(e)}\0${t??""}`,o=void 0===i?void 0:this.pluginLoads.get(i);if(o)return this.logger.debug(`Plugin ${e} already loaded, skipping`),o;const r=this.importPlugin(e,t,s);if(void 0!==i){this.pluginLoads.set(i,r);const forget=()=>{this.pluginLoads.get(i)===r&&this.pluginLoads.delete(i)};r.then(e=>e||forget(),forget)}return r}importPlugin(e,t,s){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,o)=>{import(e).then(async r=>{let n;if(r&&r.default&&(r=r.default),s){const e={...this.config,...s};n=new r(this.withConfig(e))}else n=new r(this);if(!n)return o(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>o(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await m(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,s){if(t.name||(t.name=e),s&&(t.name=s),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._compositionState.composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorState.handler&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const s=t.hooks[e];if(void 0===s)continue;if("function"!=typeof s)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);const i=e;if(j(s,t.name,e),t.roles?.includes("authz")&&L.includes(i)){const e=this.hookBus.subscribers(i),t=e.findIndex(e=>!this.authzHooks.has(e));this.hookBus.insert(i,s,t<0?e.length:t),this.authzHooks.add(s)}else this.hookBus.subscribe(i,s)}return this.loadedPlugins[t.name]=t,this.registerServices(t),!0}registerServices(e){for(const t of e.provides??[]){const s=this.serviceOwners.get(t.token);void 0===s?(this.services.set(t.token,t.service),this.serviceOwners.set(t.token,e.name)):this.logger.warn(`Plugin ${e.name}: service '${t.token.name}' is already provided by ${s}; this one is ignored`)}}}export{y as BadRequestError,DM,j as recordHookOwner,v as registerAttributeTypes};
@@ -53,7 +53,14 @@ export declare class DM {
53
53
  /** The plugin that provided each service, for the duplicate warning. */
54
54
  private readonly serviceOwners;
55
55
  ldap: ldapActions;
56
- operationSequence: number;
56
+ /**
57
+ * The next operation number, shared by the `withConfig` views. An accessor
58
+ * over an object because a field would be copied into each view — which
59
+ * would then count on its own — while an object is copied by reference.
60
+ */
61
+ private readonly _operations;
62
+ get operationSequence(): number;
63
+ set operationSequence(value: number);
57
64
  logger: winston.Logger;
58
65
  /** Authentication plugins, in registration order */
59
66
  authenticators: AuthBase[];
@@ -66,15 +73,25 @@ export declare class DM {
66
73
  * shared.
67
74
  */
68
75
  private _authState;
69
- private _errorMiddlewareSetup;
76
+ /**
77
+ * The mounted error middleware, if any. In an object, as `_authState` is: a
78
+ * view made during loading would copy a bare `undefined` field, so a route
79
+ * it registers after `ready` would land behind the handler — the one place
80
+ * Express never routes an error to.
81
+ */
82
+ private readonly _errorState;
70
83
  /** The parsers plugins mount ahead of the global ones, see registerBodyParser */
71
84
  private readonly bodyParsers;
72
85
  /**
73
86
  * Whether the plugins of the configuration are loaded and their
74
87
  * composition checked. A plugin registered after that — a server
75
88
  * assembled by hand — is checked on its own as it arrives.
89
+ *
90
+ * In an object, as `_authState` is: a plain flag is copied into each
91
+ * `withConfig` view, and a view made during loading would keep `false` and
92
+ * let a plugin registered through it skip the composition check.
76
93
  */
77
- private _composed;
94
+ private readonly _compositionState;
78
95
  /** Every plugin loaded and the composition checked */
79
96
  get composed(): boolean;
80
97
  /**
@@ -90,7 +107,6 @@ export declare class DM {
90
107
  get stopping(): boolean;
91
108
  /** Rejections nobody handled since the start, logged and counted */
92
109
  get unhandledRejections(): number;
93
- private _errorMiddleware?;
94
110
  /**
95
111
  * The loads of `--plugin` entries without overrides, keyed by resolved
96
112
  * module and name. Regular plugins load in parallel and load their own
package/docs/README.md CHANGED
@@ -34,12 +34,21 @@ Integrate LDAP-Rest into your web applications.
34
34
  - **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation
35
35
  - **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration
36
36
 
37
+ ### [Architecture](architecture/README.md)
38
+
39
+ How the server is put together.
40
+
41
+ - **[Overview](architecture/README.md)** - Module map, life of a request, life of a write
42
+ - **[Decision records](architecture/adr/)** - Why the code is the way it is
43
+
37
44
  ### [Development](development/contracts.md)
38
45
 
39
46
  Work on ldap-rest itself.
40
47
 
41
48
  - **[Published contracts](development/contracts.md)** - What breaks a
42
49
  contract, the snapshots that detect it and the deprecation window.
50
+ - **[Modularization plan](development/modularization-plan.md)** - How the
51
+ `@ldap-rest/*` split and the remaining code-quality issues are sequenced.
43
52
 
44
53
  ## API stability
45
54
 
@@ -0,0 +1,180 @@
1
+ # Architecture
2
+
3
+ How the server is put together, and the two paths that carry most of the
4
+ work. Every design decision the code already embodies is recorded once, in
5
+ [`adr/`](adr/); this page is the map and the tour.
6
+
7
+ The plan that will split the repository into packages is
8
+ [Modularization plan](../development/modularization-plan.md). That plan is a
9
+ roadmap; this page describes the code as it is.
10
+
11
+ ## Module map
12
+
13
+ | Path | Role |
14
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `src/bin/index.ts` | `DM`: the Express app, plugin loading, the authentication dispatcher, `ready` and `stop`, `withConfig` |
16
+ | `src/config/` | Command-line and environment parsing (`args.ts`), option specs (`options.ts`), the entity-schema model (`schema.ts`; the directory's own schema is `src/lib/ldap/schema.ts`) |
17
+ | `src/abstract/` | `DmPlugin` base class; `LdapFlat`, the entity CRUD built on it; `TwakePlugin` |
18
+ | `src/hooks.ts` | The hook type map: one entry per hook, with its arguments and its answer |
19
+ | `src/lib/` | Core services: `ldapActions` and `lib/ldap/` (pool, search cache, schema), `lib/auth/`, `lib/authz/`, `lib/services/`, `lib/storage/`, `hookBus`, `requestContext`, errors, routes |
20
+ | `src/plugins/auth/` | Authentication plugins, authorization plugins, and the guards that sit before them |
21
+ | `src/plugins/ldap/` | Directory plugins: groups, organizations, entities, change detection, trash, password policy |
22
+ | `src/plugins/scim/` | The SCIM 2.0 API |
23
+ | `src/plugins/storage/` | Storage backends (`file`, `ldap`, `postgres`, `valkey`), each a `Store` |
24
+ | `src/plugins/twake/` | Twake integrations: mail, calendar, spaces, drives, tombstones, lifecycle events |
25
+ | `src/plugins/*.ts` | Standalone plugins: `storage` (`core/storage`, over the backends above), `static`, `weblogs`, `configApi`, `metrics`, `bcl`, and `rabbitmq` (a shared broker connection, not a store) |
26
+ | `src/plugins/demo/` | Example plugins |
27
+ | `src/browser/` | Browser libraries shipped with the package |
28
+ | `src/logger/` | Log formatting and levels |
29
+ | `src/types/` | Shared TypeScript declarations |
30
+
31
+ The plugin families depend on `src/lib/` and on each other as documented in
32
+ [dependencies](../plugin-development/dependencies.md). The dependency rules
33
+ that the modularization will enforce are in the
34
+ [plan](../development/modularization-plan.md#target-architecture).
35
+
36
+ ## Life of a request
37
+
38
+ ```mermaid
39
+ flowchart TD
40
+ A[HTTP request] --> B[request id and log context]
41
+ B --> C[body parsers]
42
+ C --> D[protect plugins: rate limit, CrowdSec, trusted proxy]
43
+ D --> E[authentication dispatcher]
44
+ E --> F{most specific prefix}
45
+ F -->|one authenticator| G[vouched]
46
+ F -->|several on the same prefix| H[all of them, an AND]
47
+ H --> G
48
+ G --> I[runForRequest]
49
+ I --> J[route: checked params, query, body]
50
+ J --> K[ldap.forRequest or ldap.system]
51
+ K --> L[request hooks, authorization first]
52
+ L --> M[(directory)]
53
+ M --> N[done hooks of a write, then end hooks]
54
+ N --> O[onLdap* notifications]
55
+ J --> P[error middleware]
56
+ ```
57
+
58
+ 1. `DM` mounts, in this order: the request id and log context
59
+ (`runWithLogContext`), the media-type guard, the parsers a plugin
60
+ registered for its own prefix, the global JSON and form parsers (they keep
61
+ the raw bytes: HMAC authentication hashes them as the client signed them),
62
+ the health probes, the protect and logging plugins, the authentication
63
+ dispatcher, the plugin routes, and the error middleware, kept last.
64
+ 2. The dispatcher picks the authentication whose path prefix is the most
65
+ specific. Several plugins sharing that winning prefix all run, in
66
+ registration order, and every one must let the request through: two
67
+ credentials on one branch are an AND. A path nobody claims is guarded by
68
+ the unscoped authenticators (no prefix), if any are loaded; with none, the
69
+ request passes through unauthenticated, and `warnUnauthenticatedRoutes`
70
+ names those routes at startup.
71
+ 3. A plugin's `afterChain` runs after the whole selected chain and before any
72
+ route: it is the only place where a verdict can depend on several
73
+ authenticators without registration order deciding the answer.
74
+ 4. `runForRequest(req, next)` wraps everything downstream of a request an
75
+ authenticator let through — not the pass-through path, which has no
76
+ caller. A directory operation made there, by whatever plugin, is judged as
77
+ that caller even if the request object was not handed down. Hooks are the
78
+ exception: they run as the server (see below), and a hook acting as the
79
+ caller names the request it received. `ldap.system` is the explicit escape
80
+ anywhere else.
81
+ 5. A route declares its input schemas with `defineRoute`; the handler receives
82
+ checked `params`, `query` and `body`, and reaches the directory through
83
+ `ldap.forRequest(req)`. Work the server does for itself goes through
84
+ `ldap.system`.
85
+ 6. `ldapActions` raises the request hooks (authorization plugins first), makes
86
+ the operation, then raises the "done" and "end" hooks of a write. The next
87
+ section follows one.
88
+
89
+ The decisions behind these steps: [authentication
90
+ dispatch](adr/0002-authentication-dispatch.md), [authorization
91
+ composition](adr/0003-authorization-composition.md), [fail-closed request
92
+ context](adr/0004-fail-closed-request-context.md).
93
+
94
+ ## Life of a write
95
+
96
+ ```mermaid
97
+ sequenceDiagram
98
+ participant R as route
99
+ participant L as ldapActions
100
+ participant H as plugins
101
+ participant D as directory
102
+
103
+ R->>L: add / modify / rename / delete
104
+ L->>H: request hook, chained (authorization first)
105
+ H-->>L: value, changed or refused
106
+ L->>L: drop cached reads the write changes
107
+ L->>D: LDAP add / modify / modifyDN / delete
108
+ D-->>L: result
109
+ L->>L: drop them again
110
+ L->>H: done hooks, notified
111
+ L->>H: end hooks, once the done hooks returned
112
+ H->>H: onLdapEntryChange, onLdapChange, onLdapMailChange…
113
+ ```
114
+
115
+ - **Before the write.** A request hook is chained: each subscriber gets what
116
+ the previous one answered, and a refusal thrown by any of them stops the
117
+ operation. The request hooks of authorization plugins run before every
118
+ other subscriber's, so a caller who may not make the operation is refused
119
+ before a plugin judges its content.
120
+ [trash](adr/0007-trash-and-tombstone.md) and
121
+ [tombstone](adr/0007-trash-and-tombstone.md) use this point to replace a
122
+ delete.
123
+ - **The write.** `ldapActions` drops the cached reads of what it is about to
124
+ change before the directory call and again once it returned, so a read
125
+ landing during the round trip cannot serve what the write replaced.
126
+ [Search cache](adr/0006-search-cache.md).
127
+ - **After the write.** `ldapadddone`, `ldapmodifydone`, `ldapdeletedone` and
128
+ `ldaprenamedone` are notified, not awaited. `ldapaddafter` is awaited
129
+ within the request, for what must follow an add, such as provisioning the
130
+ account the entry describes. Once the done subscribers returned, the
131
+ matching `ldapmodifyend`, `ldapdeleteend` or `ldaprenameend` fires, whether
132
+ the write landed, was refused or was taken out of the request.
133
+ - **Derived notifications.** `core/ldap/onChange` reads the entry before and
134
+ after the write and raises `onLdapEntryChange`, then the attribute-specific
135
+ ones (`onLdapMailChange`, `onLdapQuotaChange`, …). A delete fires
136
+ `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange` only.
137
+ - **Contract.** [Hook contract](adr/0005-hook-contract.md), and the reference
138
+ of every hook in [hooks](../plugin-development/hooks.md). Subscribers run as
139
+ the server, outside the request that raised the hook; one that acts as the
140
+ caller names the request in its arguments.
141
+
142
+ ## Startup
143
+
144
+ The configuration is parsed from the command line and `DM_*` variables, then
145
+ `DM` loads the plugins: `src/plugins/priority.json` first, one at a time in
146
+ its order, then the rest in parallel. Once every load settled, the server
147
+ checks the authorization composition, learns the directory's attribute types
148
+ when an authorization plugin is loaded, reports unknown options and runs
149
+ `afterLoad`. `DM.ready` resolves then, and rejects if a plugin failed to load;
150
+ the CLI opens the port after it. An embedding host may register plugins after
151
+ `ready`, and each registration is checked against the composition too.
152
+ `warnUnauthenticatedRoutes` stays silent when no authentication plugin is
153
+ loaded; the enforcement check says once whether that is an open server or a
154
+ configuration to refuse.
155
+
156
+ [Plugin load order](adr/0001-plugin-load-order.md),
157
+ [server views](adr/0008-withconfig-views.md).
158
+
159
+ ## Decision records
160
+
161
+ | Record | Decision |
162
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------- |
163
+ | [0001](adr/0001-plugin-load-order.md) | Plugins load priority-first, then in parallel; dependencies load on demand |
164
+ | [0002](adr/0002-authentication-dispatch.md) | The most specific authentication prefix wins; several on one prefix are an AND |
165
+ | [0003](adr/0003-authorization-composition.md) | Two authorization models judging the same requests are refused unless made deliberate |
166
+ | [0004](adr/0004-fail-closed-request-context.md) | Directory operations inherit the request being served; `ldap.system` is the escape |
167
+ | [0005](adr/0005-hook-contract.md) | Chained hooks transform and refuse; notified hooks observe, outside the request |
168
+ | [0006](adr/0006-search-cache.md) | The search cache is off by default and only the process's own writes invalidate it |
169
+ | [0007](adr/0007-trash-and-tombstone.md) | A delete becomes a move to trash or a tombstone, never a loss |
170
+ | [0008](adr/0008-withconfig-views.md) | A plugin with overrides sees a server view that shares everything but the configuration |
171
+
172
+ ## Checking the comments
173
+
174
+ `npm run check:comments` (`scripts/check-comments.sh`) refuses new comments in
175
+ `src/` that cite an issue number, narrate history ("used to", "since 1.2") or
176
+ otherwise carry rationale. The rationale belongs in a decision record; the
177
+ comment points at it. The accepted set is `scripts/comment-baseline.txt`, and
178
+ it may only shrink: removing a comment is fine, adding one fails the check.
179
+ `sh scripts/check-comments.sh --update` rewrites the baseline from the current
180
+ tree when a change removes one.
@@ -0,0 +1,50 @@
1
+ # 0001 — Plugin load order
2
+
3
+ ## Context
4
+
5
+ Plugins are listed with `--plugin`, in the order the administrator writes
6
+ them, and a plugin may declare `dependencies` naming modules that must be
7
+ registered before it. Some plugins cannot simply be imported in parallel with
8
+ the rest: a guard whose Express middleware must sit ahead of the routes it
9
+ protects, an access log that must see every request, an authenticator or URL
10
+ gate other plugins must find registered when they register. Import order in
11
+ the parallel batch is the order the imports happen to complete, which is not a
12
+ configuration.
13
+
14
+ ## Decision
15
+
16
+ `src/plugins/priority.json` lists the modules loaded first. That pass is
17
+ sequential, in the file's order, and every instance of a priority module is
18
+ loaded there, in configuration order — matching is on the module, not on the
19
+ whole `--plugin` entry, so a named instance (`module:name:{…}`) keeps its
20
+ rank. The remaining entries load in parallel. A declared dependency is loaded
21
+ on demand when the plugin that needs it registers. A module requested twice
22
+ under the same name without overrides is loaded once; a second instance needs
23
+ its own name. Two plugins with the same instance name are refused, and the
24
+ second is dropped with a warning. `configApi` loads last.
25
+
26
+ The list holds four kinds of plugin: the guards whose middleware must run
27
+ before the routes (`trustedProxy`, `crowdsec`, `rateLimit`), the access log
28
+ that must see every request (`weblogs`), the authenticators the dispatcher and
29
+ the authorization plugins naming them have to find registered (`token`,
30
+ `fake`, `llng`, `openidconnect`), and the URL gate whose rules must exist
31
+ before the routes they judge (`authzPerRoute`). `core/storage` is there
32
+ because others must find it registered when they register.
33
+
34
+ Hooks run in load order, except the request hooks of authorization plugins,
35
+ which run before every other subscriber's.
36
+
37
+ ## Consequences
38
+
39
+ - A plugin whose middleware must precede routes has to be in
40
+ `priority.json`; being written first in the configuration is not enough
41
+ once the parallel batch starts.
42
+ - The relative order of two plugins that are neither priority nor linked by a
43
+ dependency is not defined, and no plugin may rely on it.
44
+ - Adding a module to the priority list changes the order for every
45
+ deployment, so the list stays short.
46
+ - An instance named with overrides loads at the rank of its module but keeps
47
+ its own configuration through a [server view](0008-withconfig-views.md).
48
+
49
+ See [dependencies](../../plugin-development/dependencies.md) and
50
+ `src/bin/index.ts`.
@@ -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
@@ -31,10 +31,15 @@ contract shows up as a diff instead of a surprise after the release:
31
31
  - `openapi.json` is the REST API document the published reference is built
32
32
  from. Regenerate it with `npm run generate:openapi`; `npm run check:openapi`
33
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.
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).
38
43
 
39
44
  The snapshot records what the parser reads. A narrowed `choices` list, a
40
45
  raised `min` or a lowered `max` is checked when a plugin reads its options,
@@ -59,8 +64,13 @@ that becomes required, a response that may now be null, changed security
59
64
  requirements, a removed union variant, a value an alternative of the type
60
65
  closes with an enum or a `const` where the type used to accept any, an
61
66
  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
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
64
74
  long as the old CLI name and environment variable stay in its `aliases` and
65
75
  `envAliases`. Adding a union variant, a field or an optional parameter is
66
76
  additive, and so is a request union with an alternative that still accepts
@@ -0,0 +1,197 @@
1
+ # Modularization plan
2
+
3
+ How the remaining architecture work is sequenced: [#397] and the five
4
+ `code-quality` issues that depend on it or prepare it. The plan is a roadmap,
5
+ not a contract; update it as the work lands.
6
+
7
+ ## Open issues
8
+
9
+ | Issue | Subject | Role | Size |
10
+ | ------ | ----------------------------------------------------------------------------------- | ----------------------------- | ------ |
11
+ | [#397] | Split the repository into `@ldap-rest/*` modules | Final step | Major |
12
+ | [#593] | Finish the plugin service migration: entity registry, call sites, family boundaries | Prerequisite of #397 | Large |
13
+ | [#594] | Finish the `ldapActions` split: operation pipeline and façade | Prerequisite of #397 | Medium |
14
+ | [#595] | Snapshot the plugin API types and gate changes | Safety net, start before #397 | Medium |
15
+ | [#549] | Architecture pages, decision records, comment check | Independent, start early | Medium |
16
+ | [#603] | Enable the type-checked ESLint rules the test block leaves out | Independent, quick win | Small |
17
+
18
+ [#397] is still in the `2.0.0` milestone, which is released. Retarget it to a
19
+ 2.x epic or to 3.0.0 once the compatibility decision below is made. [#593] and
20
+ [#594] are labelled `Minor`/`backlog` but are the real prerequisites of
21
+ [#397]; promote them to the same milestone.
22
+
23
+ ## What blocks #397 today
24
+
25
+ The core still imports plugin code:
26
+
27
+ - `src/config/args.ts` imports the option sets of every plugin family.
28
+ - `src/bin/index.ts` imports `src/plugins/priority.json`.
29
+ - `src/hooks.ts` imports `ChangesToNotify` from
30
+ `src/plugins/ldap/onChange.ts` and the SCIM types from
31
+ `src/plugins/scim/types.ts`.
32
+ - `src/abstract/ldapFlat.ts` reads `server.loadedPlugins` and uses
33
+ `requirePlugin`.
34
+ - `src/lib/ldapActions.ts` is 1,375 lines; `src/lib/ldap/operations.ts` does
35
+ not exist.
36
+ - The single package generates 66 `./plugin-*` exports from
37
+ `rollup.config.mjs`, with no npm workspaces.
38
+
39
+ Between plugin families the coupling is smaller: only the Twake plugins import
40
+ `ldap`/`scim` (15 imports), plus `TwakeGroups extends LdapGroups`.
41
+
42
+ ## Target architecture
43
+
44
+ An npm workspaces monorepo with an umbrella package that preserves the public
45
+ API:
46
+
47
+ ```text
48
+ packages/
49
+ core/ @ldap-rest/core
50
+ plugin-auth/ @ldap-rest/plugin-auth
51
+ plugin-ldap/ @ldap-rest/plugin-ldap
52
+ plugin-scim/ @ldap-rest/plugin-scim
53
+ plugin-storage/ @ldap-rest/plugin-storage
54
+ plugin-twake/ @ldap-rest/plugin-twake
55
+ plugin-misc/ @ldap-rest/plugin-misc
56
+ browser/ @ldap-rest/browser
57
+ schemas/ @ldap-rest/schemas
58
+ ldap-rest/ umbrella package (CLI and re-exports)
59
+ ```
60
+
61
+ Rules:
62
+
63
+ - `core` depends on no family; a family depends on no other family. Cross-family
64
+ needs go through services (`src/lib/services`) or hooks.
65
+ - `TwakeGroups extends LdapGroups` is either the one documented exception or
66
+ the shared base moves to `core`.
67
+ - `ldap-rest` stays the entry package: it depends on every package, keeps the
68
+ CLI and the `core/…` namespace, and re-exports the former sub-paths
69
+ (`ldap-rest/plugin-auth-authzlinid1`, …). The split is therefore not breaking
70
+ for users.
71
+ - The core parser accepts an injected `OptionSet`; the umbrella composes the
72
+ option sets of every family. `Config` may stay a type-only aggregate in
73
+ `core`, but runtime option imports leave the core.
74
+ - Each package builds on its own with a shared configuration; the umbrella
75
+ re-exports. The Twake-specific `bin/` scripts move to their package or to a
76
+ `@ldap-rest/cli` package.
77
+
78
+ A package per plugin (`@ldap-rest/authz-linid1`, …) is possible, but start with
79
+ families and extract optional plugins individually later. Otherwise the first
80
+ day costs 66 packages and 66 release cycles.
81
+
82
+ ## Phases
83
+
84
+ ### Phase 0 — Guardrails and quick wins
85
+
86
+ 1. [#603]: enable the type-checked rules on `test/**/*.ts`, fix the 31
87
+ `no-floating-promises` and 24 `await-thenable` reports, add the block to CI.
88
+ 2. [#549], part 1: `docs/architecture/README.md` (module map, life of a
89
+ request, life of a write) and the decision records for the decisions already
90
+ in place. Add `scripts/check-comments.sh` with a baseline that may only
91
+ shrink. The GitHub ruleset change can land separately.
92
+ 3. [#595], part 1: `@microsoft/api-extractor` on the five main entry points
93
+ (`ldap-rest`, `./plugin`, `./hooks`, `./ldapactions`,
94
+ `./expressformatedresponses`), reports under `etc/api/`, wired into the
95
+ `contracts` job. The 66 `./plugin-*` entries wait for phase 3.
96
+
97
+ Outcome: CI catches regressions, the current architecture is documented, and
98
+ the public types have a baseline.
99
+
100
+ ### Phase 1 — Decouple the core
101
+
102
+ 1. [#593]:
103
+ - typed entity registry replacing the six hand-written loops;
104
+ - `GroupsApi`, `OrganizationsApi`, `TrashApi`, `PasswordPolicyApi`,
105
+ `TwakeGroupsApi`, `AuthenticatorsInfo` service interfaces;
106
+ - replace the 68 `loadedPlugins` reads and the 17 `requirePlugin` uses;
107
+ - move `ChangesToNotify` out of `plugins/ldap/onChange.ts` and the SCIM
108
+ types out of `plugins/scim/` into `src/lib/`;
109
+ - ESLint: forbid `loadedPlugins` under `src/plugins/**` and imports between
110
+ plugin families.
111
+ 2. [#594]: extract the operation pipeline into `src/lib/ldap/operations.ts`,
112
+ bring `ldapActions.ts` under 400 lines, add the file-size test, move the
113
+ ESLint exemption, add the type test on `ldap-rest/ldapactions`.
114
+ 3. Options refactor (needs its own issue): split `src/config/args.ts` into core
115
+ options and per-family option sets; let the parser/`DM` accept an
116
+ `OptionSet`; move `priority.json` into the core or make it a parameter.
117
+
118
+ Outcome: `src/hooks.ts` imports no plugin, the core reads no `loadedPlugins`,
119
+ families do not import each other, and `ldapActions` is a stable façade.
120
+
121
+ ### Phase 2 — Workspaces and extraction
122
+
123
+ 1. Set up npm workspaces, `packages/`, a shared `tsconfig` and per-package
124
+ builds.
125
+ 2. Extract `@ldap-rest/core` first, with the `ldap-rest` umbrella depending on
126
+ it and re-exporting the existing entry points.
127
+ 3. Extract the families in dependency order: storage → auth → ldap → scim →
128
+ twake → misc.
129
+ 4. Extract `browser` and `schemas`.
130
+ 5. After each step: `npm run test:dev`, `npm run check`,
131
+ `npm run check:openapi`, `npm run check:options`, and the `contracts` job.
132
+ 6. Update the Dockerfile, the `bin/` scripts, the examples and the docs.
133
+
134
+ Outcome: a modular repository, with `ldap-rest` still installable and
135
+ compatible.
136
+
137
+ ### Phase 3 — Contracts and finalization
138
+
139
+ 1. [#595], part 2: one api-extractor report per sub-path, including the
140
+ `./plugin-*` entries, and non-additive diffs refused by `contracts`.
141
+ 2. [#549], part 2: the modularization decision record, a shrinking comment
142
+ baseline, and the `require_code_owner_review` ruleset.
143
+ 3. Publish the scoped packages and document the deprecation policy for the
144
+ former sub-paths.
145
+ 4. Close [#397].
146
+
147
+ ## Branch strategy
148
+
149
+ Phase 0 lands on `master`: it is non-breaking, and #603 fixes test bugs that
150
+ matter to the default branch too. Merge `master` into `v3.0` before phase 1,
151
+ so the 3.0 work starts from a green, guarded base. Phases 1 to 3 land on
152
+ `v3.0`.
153
+
154
+ ## Order
155
+
156
+ ```text
157
+ #603 ───────────────┐
158
+ #549 (part 1) ──────┼──> Phase 0
159
+ #595 (part 1) ──────┘
160
+ │
161
+ #593 ────────────────┼──> Phase 1 ──> #397 (Phase 2) ──> #595/#549 (part 2)
162
+ #594 ────────────────┘
163
+ ```
164
+
165
+ [#603] and [#549] part 1 can start in parallel. [#595] part 1 is a short
166
+ spike. [#593] and [#594] can then run in parallel, but both touch the core:
167
+ freeze new cross-family imports and new `loadedPlugins` reads from the start of
168
+ phase 1.
169
+
170
+ ## Decisions to make
171
+
172
+ 1. **Granularity**: packages per family (recommended) or one package per plugin
173
+ from the start?
174
+ 2. **Versioning**: a non-breaking internal split in 2.x with the umbrella
175
+ (recommended), or 3.0.0 with the former sub-paths removed?
176
+ 3. **`TwakeGroups extends LdapGroups`**: documented exception, or move the
177
+ shared base into `core`?
178
+ 4. **`Config` type**: type-only aggregate in `core` (recommended to start), or
179
+ split per package with declaration merging?
180
+ 5. **Build**: Rollup per package (consistent with today) or `tsc` with
181
+ generated exports?
182
+
183
+ ## First actions
184
+
185
+ 1. Retarget [#397] and add the package map and the dependency rule to it.
186
+ 2. Open sub-issues for [#593] and [#594]; both are too large for one pull
187
+ request.
188
+ 3. Start [#603] and [#549] part 1 in parallel.
189
+ 4. Spike [#595] on the five main entry points.
190
+ 5. Then [#593]/[#594], then the package extraction.
191
+
192
+ [#397]: https://github.com/linagora/ldap-rest/issues/397
193
+ [#549]: https://github.com/linagora/ldap-rest/issues/549
194
+ [#593]: https://github.com/linagora/ldap-rest/issues/593
195
+ [#594]: https://github.com/linagora/ldap-rest/issues/594
196
+ [#595]: https://github.com/linagora/ldap-rest/issues/595
197
+ [#603]: https://github.com/linagora/ldap-rest/issues/603
@@ -24,7 +24,8 @@ subscriber answers:
24
24
  A chained hook is handed its arguments as one value, a tuple when it has
25
25
  several. A notified hook is handed them one by one: its subscriber is
26
26
  `(dn, entry, context) => …`, never `([dn, entry], context) => …` — that form
27
- still compiles, the parameters being bivariant, but then reads the DN's first
27
+ still compiles without annotation, since its parameter takes the declared
28
+ `string` and a string can be destructured, but then reads the DN's first
28
29
  character as the DN.
29
30
 
30
31
  - **Chained.** The value goes from one subscriber to the next, each getting
@@ -55,9 +55,12 @@ changes nothing.
55
55
 
56
56
  The image is built in two stages: a builder produces the package and installs
57
57
  its production dependencies, the runtime stage copies them and carries no
58
- compiler nor package manager cache. Its base image is pinned by digest in
59
- `scripts/buildDockerfile.ts`, so the image built is the one reviewed even if
60
- the tag moves; the digest follows the tag it names, taken from the
58
+ compiler nor package manager cache. The runtime stage upgrades the Alpine
59
+ packages of its base image with `apk upgrade --no-cache`, so a security fix
60
+ published after the base image was built still reaches the image: two builds
61
+ from the same digest may differ in those packages. Its base image is pinned by
62
+ digest in `scripts/buildDockerfile.ts`, so the image built is the one reviewed
63
+ even if the tag moves; the digest follows the tag it names, taken from the
61
64
  [Docker Hub tag page](https://hub.docker.com/_/node/tags?name=25-alpine) and
62
65
  updated by hand with it. Dependabot ignores the image: it would open a change
63
66
  to the generated `Dockerfile`, which the drift check refuses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -806,7 +806,9 @@
806
806
  "build:pages": "tsx scripts/build-pages.ts",
807
807
  "build:prod": "npm run clean && NODE_ENV=production rollup -c && npm run build:browser && node scripts/moveBrowserLibs.mjs && npm run build:dockerfile",
808
808
  "build:watch": "rollup -c --watch",
809
- "check": "npm run check:ts && npm run lint && npm run format:check && npm run check:hooks && npm run check:no-client-values && npm run check:openapi && npm run check:options",
809
+ "check": "npm run check:ts && npm run lint && npm run format:check && npm run check:hooks && npm run check:comments && npm run check:no-client-values && npm run check:api && npm run check:openapi && npm run check:options",
810
+ "check:api": "tsx scripts/api-snapshot.ts --check",
811
+ "check:comments": "sh scripts/check-comments.sh",
810
812
  "check:hooks": "tsx scripts/hooks-doc.ts",
811
813
  "check:no-client-values": "sh scripts/check-no-client-values.sh",
812
814
  "check:openapi": "tsx scripts/check-openapi.ts",
@@ -825,6 +827,7 @@
825
827
  "generate:openapi": "tsx scripts/generate-openapi.ts",
826
828
  "lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\" \"test/**/*.ts\"",
827
829
  "lint:fix": "eslint \"src/**/*.{ts,tsx,js,jsx}\" \"test/**/*.ts\" --fix",
830
+ "snapshot:api": "tsx scripts/api-snapshot.ts",
828
831
  "snapshot:options": "tsx scripts/options-snapshot.ts",
829
832
  "start:dev": "./.dev.mk start",
830
833
  "test": "npm run check:ts && mocha",
@@ -853,6 +856,7 @@
853
856
  },
854
857
  "devDependencies": {
855
858
  "@eslint/js": "^10.0.1",
859
+ "@microsoft/api-extractor": "^7.60.1",
856
860
  "@rollup/plugin-commonjs": "^29.0.3",
857
861
  "@rollup/plugin-json": "^6.1.0",
858
862
  "@rollup/plugin-terser": "^1.0.2",