ldap-rest 0.20.1 → 0.20.3

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 (79) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/config/schema.js +1 -1
  3. package/dist/lib/expressFormatedResponses.js +1 -1
  4. package/dist/lib/instanceProviders.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  7. package/dist/plugins/ldap/groups.js +1 -1
  8. package/dist/plugins/ldap/organizations.js +1 -1
  9. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  10. package/dist/plugins/ldap/raw.js +1 -1
  11. package/dist/plugins/twake/clouderyProvision.js +1 -1
  12. package/dist/plugins/twake/instances.js +1 -1
  13. package/dist/src/browser/ldap-group-editor/api/GroupApiClient.d.ts +10 -5
  14. package/dist/src/browser/ldap-group-editor/types.d.ts +8 -0
  15. package/dist/src/browser/ldap-resource-editor/api/ResourceApiClient.d.ts +7 -1
  16. package/dist/src/browser/ldap-unit-editor/api/UnitApiClient.d.ts +7 -5
  17. package/dist/src/browser/ldap-user-editor/LdapUserEditor.d.ts +2 -0
  18. package/dist/src/browser/ldap-user-editor/api/UserApiClient.d.ts +7 -1
  19. package/dist/src/browser/shared/utils/form.d.ts +30 -0
  20. package/dist/src/config/schema.d.ts +30 -1
  21. package/dist/src/lib/instanceProviders.d.ts +19 -0
  22. package/dist/src/lib/ldapActions.d.ts +9 -0
  23. package/dist/src/plugins/ldap/accountLifecycle.d.ts +2 -0
  24. package/dist/src/plugins/twake/clouderyProvision.d.ts +7 -1
  25. package/dist/src/plugins/twake/instances.d.ts +6 -2
  26. package/docs/client-development/api/rest-api.md +19 -0
  27. package/docs/usage/plugins/integrations/instances.md +18 -5
  28. package/docs/usage/plugins/ldap/account-lifecycle.md +9 -1
  29. package/docs/usage/upgrading.md +42 -0
  30. package/examples/web/README.md +5 -1
  31. package/examples/web/ldap-user-editor.html +9 -15
  32. package/examples/web/totp-client.html +51 -14
  33. package/package.json +1 -1
  34. package/static/browser/ldap-group-editor/api/GroupApiClient.js +27 -13
  35. package/static/browser/ldap-group-editor/api/GroupApiClient.js.map +1 -1
  36. package/static/browser/ldap-group-editor/components/CreateGroupModal.js +5 -17
  37. package/static/browser/ldap-group-editor/components/CreateGroupModal.js.map +1 -1
  38. package/static/browser/ldap-group-editor/components/GroupPropertyEditor.js +28 -12
  39. package/static/browser/ldap-group-editor/components/GroupPropertyEditor.js.map +1 -1
  40. package/static/browser/ldap-group-editor/components/MoveGroupModal.js.map +1 -1
  41. package/static/browser/ldap-group-editor.esm.js +114 -42
  42. package/static/browser/ldap-group-editor.esm.js.map +1 -1
  43. package/static/browser/ldap-group-editor.js +114 -42
  44. package/static/browser/ldap-group-editor.js.map +1 -1
  45. package/static/browser/ldap-group-editor.min.js +1 -1
  46. package/static/browser/ldap-group-editor.min.js.map +1 -1
  47. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js +8 -2
  48. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js.map +1 -1
  49. package/static/browser/ldap-unit-editor/api/UnitApiClient.js +8 -13
  50. package/static/browser/ldap-unit-editor/api/UnitApiClient.js.map +1 -1
  51. package/static/browser/ldap-unit-editor/components/UnitPropertyEditor.js +5 -2
  52. package/static/browser/ldap-unit-editor/components/UnitPropertyEditor.js.map +1 -1
  53. package/static/browser/ldap-unit-editor.esm.js +52 -15
  54. package/static/browser/ldap-unit-editor.esm.js.map +1 -1
  55. package/static/browser/ldap-unit-editor.js +52 -15
  56. package/static/browser/ldap-unit-editor.js.map +1 -1
  57. package/static/browser/ldap-unit-editor.min.js +1 -1
  58. package/static/browser/ldap-unit-editor.min.js.map +1 -1
  59. package/static/browser/ldap-user-editor/LdapUserEditor.js +22 -21
  60. package/static/browser/ldap-user-editor/LdapUserEditor.js.map +1 -1
  61. package/static/browser/ldap-user-editor/api/UserApiClient.js +8 -2
  62. package/static/browser/ldap-user-editor/api/UserApiClient.js.map +1 -1
  63. package/static/browser/ldap-user-editor/components/UserEditor.js +19 -9
  64. package/static/browser/ldap-user-editor/components/UserEditor.js.map +1 -1
  65. package/static/browser/ldap-user-editor.esm.js +88 -32
  66. package/static/browser/ldap-user-editor.esm.js.map +1 -1
  67. package/static/browser/ldap-user-editor.js +88 -32
  68. package/static/browser/ldap-user-editor.js.map +1 -1
  69. package/static/browser/ldap-user-editor.min.js +1 -1
  70. package/static/browser/ldap-user-editor.min.js.map +1 -1
  71. package/static/browser/shared/index.js +1 -1
  72. package/static/browser/shared/utils/form.js +51 -1
  73. package/static/browser/shared/utils/form.js.map +1 -1
  74. package/static/browser/shared.esm.js +51 -1
  75. package/static/browser/shared.esm.js.map +1 -1
  76. package/static/browser/shared.js +53 -0
  77. package/static/browser/shared.js.map +1 -1
  78. package/static/browser/shared.min.js +1 -1
  79. package/static/browser/shared.min.js.map +1 -1
@@ -1 +1 @@
1
- import{setTimeout as e}from"node:timers/promises";import t from"node-fetch";import r from"../../abstract/plugin.js";import{escapeDnValue as i,isChildOf as s}from"../../lib/utils.js";import{BaseResolver as n}from"../scim/baseResolver.js";const a=new Set(["admin","owner","member"]),o=1e3;class ClouderyProvision extends r{constructor(e){super(e),this.name="clouderyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.createStash=new Map,this.deleteStash=new Map,this.hooks={scimusercreate:e=>(this.normalizeUserName(e[0]),this.captureCreate(e[0],e[1]),e),scimusercreatedone:async e=>{await this.provision(e)},scimuserdelete:async e=>(e[0]=e[0].toLowerCase(),await this.captureDelete(e[0],e[1]),e),scimuserdeletedone:async e=>{await this.deprovision(e)}};const t=this.config;this.managerUrl=(t.cloudery_manager_url||"").replace(/\/$/,""),this.managerToken=t.cloudery_manager_token||"",this.offer=t.cloudery_offer||"b2b_twake_default",this.clouderyDomain=t.cloudery_domain||"",this.userBranch=t.cloudery_user_branch||"",this.orgIdHeader=(t.cloudery_org_id_header||"x-cloudery-org-id").toLowerCase(),this.orgRoleHeader=(t.cloudery_org_role_header||"x-cloudery-org-role").toLowerCase();const r=t.cloudery_default_org_role||"member",i=r.trim().toLowerCase();a.has(i)?this.defaultOrgRole=i:(this.defaultOrgRole="member",this.logger.warn(`${this.name}: cloudery_default_org_role "${r}" is not one of ${[...a].join(", ")} — using "member"`)),this.fqdnAttribute=t.cloudery_fqdn_attribute||"twakeWorkspaceUrl",this.orgIdAttribute=t.cloudery_org_id_attribute||"twakeOrganizationId",this.orgRoleAttribute=t.cloudery_org_role_attribute||"twakeOrganizationRole",this.phonesAttribute=t.cloudery_phones_attribute||"twakePhones",this.invitedAttribute=t.cloudery_invited_attribute||"twakeInvited",this.defaultLocale=t.cloudery_default_locale||"en",this.rdnAttribute=t.scim_user_rdn_attribute||"uid",this.authExchange=t.cozy_auth_exchange||"auth",this.b2bExchange=t.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=t.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=t.cozy_user_deleted_routing_key??"domain.user.deleted",this.pollIntervalMs=Number(t.cloudery_workflow_poll_interval_ms)||2e3,this.maxPollAttempts=Number(t.cloudery_workflow_max_attempts)||60,this.baseResolver=new n(this.config),this.managerUrl||this.logger.warn(`${this.name}: cloudery_manager_url is empty — instance provisioning will be skipped`),this.userBranch||this.logger.warn(`${this.name}: cloudery_user_branch is empty — no users will be provisioned`),this.logger.info({plugin:this.name,event:"init",managerUrl:this.managerUrl||void 0,userBranch:this.userBranch||void 0,orgIdHeader:this.orgIdHeader,orgRoleHeader:this.orgRoleHeader,defaultOrgRole:this.defaultOrgRole,fqdnAttribute:this.fqdnAttribute,orgIdAttribute:this.orgIdAttribute,orgRoleAttribute:this.orgRoleAttribute,phonesAttribute:this.phonesAttribute,invitedAttribute:this.invitedAttribute,pollIntervalMs:this.pollIntervalMs,maxPollAttempts:this.maxPollAttempts})}captureCreate(e,t){this.pruneStash();const r=this.extractId(e),i=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,result:"skipped",reason:"missing_configuration"});if(!r)return void this.logger.info({plugin:this.name,event:"captureCreate",base:i,result:"skipped",reason:"missing_user_name"});if(!this.isAtOrUnder(i,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,result:"skipped",reason:"outside_b2b_branch"});const s=this.readHeader(t,this.orgIdHeader);if(!s)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:`B2B branch create without ${this.orgIdHeader} — skipping provisioning`});const n=this.extractPrimaryEmail(e);if(!n)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:"B2B branch create without a primary email — skipping provisioning"});const a=n.trim(),o=this.resolveRole(t,r);this.createStash.set(a.toLowerCase(),{orgId:s.trim(),email:a,role:o,base:i,ts:Date.now()}),this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,orgId:s.trim(),email:a,role:o,result:"stashed"})}async provision(e){const t=this.extractId(e);if(this.logger.info({plugin:this.name,event:"provision",userName:t||void 0}),!t)return void this.logger.info({plugin:this.name,event:"provision",result:"skipped",reason:"missing_user_name"});const r=this.extractPrimaryEmail(e);if(!r)return void this.logger.info({plugin:this.name,event:"provision",userName:t,result:"skipped",reason:"missing_primary_email"});const s=r.trim().toLowerCase(),n=this.createStash.get(s);if(!n)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:r.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(s),this.logger.info({plugin:this.name,event:"provision",userName:t,email:n.email,orgId:n.orgId,base:n.base,role:n.role,result:"context_loaded"});const a=n.email,o=emailDomain(a),l=t.replace(/\./g,"")+n.orgId;const u=this.extractPrimaryPhone(e),h=this.extractPhones(e),g=this.extractPublicName(e)||t,d={plugin:this.name,event:"provision",userName:t,orgId:n.orgId,slug:l},c=`${l}.${this.clouderyDomain}`.toLowerCase();let m,p=null;try{p=await this.findInstanceUuidByFqdn(c)}catch(e){this.logger.warn({...d,result:"existence_lookup_failed",fqdn:c,error:`${e}`})}if(p)this.logger.info({...d,result:"instance_exists",fqdn:c,uuid:p}),m=c;else{let t;try{this.logger.info({...d,result:"creating_instance",orgDomain:o,email:a,mobile:u||void 0}),t=await this.createInstance({email:a,publicName:g,locale:this.extractLocale(e),oidc:l,phone:u||"",slug:l,public_name:g,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:a,org_domain:o,org_id:n.orgId})}catch(e){return void this.logger.error({...d,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...d,result:"workflow_failed",workflow:t.workflow});m=t.fqdn}const f=`${this.rdnAttribute}=${i(t)},${n.base}`,w={[this.fqdnAttribute]:m,[this.orgIdAttribute]:n.orgId,[this.orgRoleAttribute]:n.role,[this.invitedAttribute]:"TRUE",cn:t};h.length>0&&(w[this.phonesAttribute]=JSON.stringify(h));try{this.logger.info({...d,result:"writing_fqdn",dn:f,hasPhones:h.length>0}),await this.server.ldap.modify(f,{replace:w}),this.logger.info({...d,result:"written_fqdn",dn:f})}catch(e){this.logger.error({...d,event:"writeFqdn",dn:f,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:o,orgId:n.orgId,fqdn:m,email:a,mobile:u}),this.logger.info({...d,result:"success",fqdn:m})}async captureDelete(e,t){const r=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"missing_configuration"});if(!this.isAtOrUnder(r,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"outside_b2b_branch"});const s=`${this.rdnAttribute}=${i(e)},${r}`;try{this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,result:"searching"});const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.fqdnAttribute,"mail"]},s),r=t.searchEntries?.[0],i=r?firstValue(r[this.fqdnAttribute]):void 0,n=i?i.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i,"").split("/")[0]:void 0;if(!n)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,result:"skipped",reason:"missing_fqdn"});const a=r?firstValue(r.mail):void 0,o=a?emailDomain(a):"";this.deleteStash.set(e,{fqdn:n,domain:o}),this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,fqdn:n,domain:o,result:"stashed"})}catch(t){this.logger.warn({plugin:this.name,event:"captureDelete",id:e,error:`${t}`})}}async deprovision(e){this.logger.info({plugin:this.name,event:"deprovision",id:e});const t=this.deleteStash.get(e);if(!t)return void this.logger.info({plugin:this.name,event:"deprovision",id:e,result:"skipped",reason:"no_stashed_context"});this.deleteStash.delete(e);const r={plugin:this.name,event:"deprovision",id:e,fqdn:t.fqdn};let i=!1;try{this.logger.info({...r,result:"resolving_uuid"});const e=await this.findInstanceUuidByFqdn(t.fqdn);e?(this.logger.info({...r,result:"deleting_instance",uuid:e}),await this.deleteInstance(e),i=!0,this.logger.info({...r,result:"deleted",uuid:e})):this.logger.warn({...r,result:"not_found"})}catch(e){this.logger.error({...r,result:"error",error:`${e}`})}i&&await this.publishUserDeleted(t.fqdn,t.domain)}async createInstance(e){this.logger.info({plugin:this.name,event:"createInstance",url:`${this.managerUrl}/api/v1/instances`});const r=await t(`${this.managerUrl}/api/v1/instances`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.managerToken}`},body:JSON.stringify(e)});if(!r.ok){const e=(await r.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"createInstance",result:"error",http_status:r.status,http_status_text:r.statusText,body:e||void 0}),new Error(`Cloudery createInstance HTTP ${r.status}`)}const i=await r.json();return this.logger.info({plugin:this.name,event:"createInstance",result:"success",id:i.id,fqdn:i.fqdn,workflow:i.workflow}),i}async waitForWorkflow(r){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,maxPollAttempts:this.maxPollAttempts,pollIntervalMs:this.pollIntervalMs});for(let i=1;i<=this.maxPollAttempts;i++){try{const e=await t(`${this.managerUrl}/api/v1/workflows/${encodeURIComponent(r)}`,{headers:{Authorization:`Bearer ${this.managerToken}`}});if(e.ok){const t=await e.json();if(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,status:t.status}),"succeeded"===t.status)return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"success"}),!0;if("failed"===t.status||"error"===t.status)return this.logger.warn({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"failed",status:t.status}),!1}else{const t=(await e.text().catch(()=>"")).slice(0,o);this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"http_error",http_status:e.status,http_status_text:e.statusText,body:t||void 0})}}catch(e){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,error:`${e}`})}i<this.maxPollAttempts&&(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"retrying"}),await e(this.pollIntervalMs))}return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,result:"timeout",maxPollAttempts:this.maxPollAttempts}),!1}async findInstanceUuidByFqdn(e){this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e});const r=new URL(`${this.managerUrl}/api/v2/instances`);r.searchParams.set("fqdn",e),r.searchParams.append("only","_id"),r.searchParams.set("limit","1");const i=await t(r.toString(),{headers:{Authorization:`Bearer ${this.managerToken}`}});if(!i.ok){const t=(await i.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e,result:"error",http_status:i.status,http_status_text:i.statusText,body:t||void 0}),new Error(`Cloudery searchInstances HTTP ${i.status}`)}const s=await i.json(),n=s.items&&s.items.length>0?s.items[0]._id:null;return this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e,result:n?"found":"not_found",uuid:n||void 0}),n}async deleteInstance(e){const r=new URL(`${this.managerUrl}/api/v1/instances/${encodeURIComponent(e)}`);r.searchParams.set("user_request","true"),this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,url:r.toString()});const i=await t(r.toString(),{method:"DELETE",headers:{Authorization:`Bearer ${this.managerToken}`}});if(!i.ok){const t=(await i.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"deleteInstance",uuid:e,result:"error",http_status:i.status,http_status_text:i.statusText,body:t||void 0}),new Error(`Cloudery deleteInstance HTTP ${i.status}`)}this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,result:"success",http_status:i.status})}async publishUserCreated(e){const t=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,orgId:e.orgId,orgDomain:e.orgDomain,hasRabbitmq:Boolean(t)}),!t)return void this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,result:"skipped",reason:"missing_rabbitmq"});const r={twakeId:e.twakeId,domain:e.orgDomain,organizationDomain:e.orgDomain,workplaceFqdn:e.fqdn,organizationId:e.orgId};e.email&&(r.internalEmail=e.email),e.mobile&&(r.mobile=e.mobile);try{await t.publish(this.authExchange,this.userCreatedRoutingKey,r),this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,error:`${t}`})}}async publishUserDeleted(e,t){if(!this.userDeletedRoutingKey)return;const r=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,domain:t,hasRabbitmq:Boolean(r)}),r)try{await r.publish(this.b2bExchange,this.userDeletedRoutingKey,{workplaceFqdn:e,domain:t}),this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,error:`${t}`})}else this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,result:"skipped",reason:"missing_rabbitmq"})}readHeader(e,t){if(!e)return;const r=e.get?e.get(t):void 0;if("string"==typeof r&&r.length>0)return r;const i=e.query?.[t];return"string"==typeof i&&i.length>0?i:void 0}resolveRole(e,t){const r=this.readHeader(e,this.orgRoleHeader)?.trim().toLowerCase();return r?a.has(r)?(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"accepted",role:r}),r):(this.logger.warn({plugin:this.name,event:"captureCreate",userName:t,message:`Unknown ${this.orgRoleHeader} "${r}" — falling back to ${this.defaultOrgRole}`}),this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole):(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole)}isAtOrUnder(e,t){return e.trim().toLowerCase()===t.trim().toLowerCase()||s(e,t)}pruneStash(){const e=Date.now()-3e5;for(const[t,r]of this.createStash)r.ts<e&&this.createStash.delete(t)}normalizeUserName(e){"string"==typeof e.userName&&(e.userName=e.userName.toLowerCase())}extractId(e){return"string"==typeof e.userName&&e.userName.length>0?e.userName:"string"==typeof e.id&&e.id.length>0?e.id:null}extractPrimaryEmail(e){const t=e.emails;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPrimaryPhone(e){const t=e.phoneNumbers;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPhones(e){const t=e.phoneNumbers;return t&&0!==t.length?t.filter(e=>"string"==typeof e.value&&e.value.trim().length>0).map(e=>({number:e.value.trim(),primary:Boolean(e.primary)})):[]}extractPublicName(e){if("string"==typeof e.displayName&&e.displayName.trim().length>0)return e.displayName.trim();const t=e.name?.formatted;if("string"==typeof t&&t.trim().length>0)return t.trim();const r=`${e.name?.givenName?.trim()??""} ${e.name?.familyName?.trim()??""}`.trim();return r.length>0?r:null}extractLocale(e){return"string"==typeof e.locale&&e.locale.length>0?e.locale:"string"==typeof e.preferredLanguage&&e.preferredLanguage.length>0?e.preferredLanguage:this.defaultLocale}}function emailDomain(e){const t=e.lastIndexOf("@");return t>=0?e.slice(t+1):""}function firstValue(e){if(Array.isArray(e)){const t=e[0];return"string"==typeof t?t:void 0}return"string"==typeof e?e:void 0}export{ClouderyProvision as default};
1
+ import{setTimeout as e}from"node:timers/promises";import t from"node-fetch";import r from"../../abstract/plugin.js";import{instanceSlug as i}from"../../lib/instanceProviders.js";import{escapeDnValue as s,isChildOf as n}from"../../lib/utils.js";import{BaseResolver as a}from"../scim/baseResolver.js";const o=new Set(["admin","owner","member"]),l=1e3;class ClouderyProvision extends r{constructor(e){super(e),this.name="clouderyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.createStash=new Map,this.deleteStash=new Map,this.hooks={scimusercreate:e=>(this.normalizeUserName(e[0]),this.captureCreate(e[0],e[1]),e),scimusercreatedone:async e=>{await this.provision(e)},scimuserdelete:async e=>(e[0]=e[0].toLowerCase(),await this.captureDelete(e[0],e[1]),e),scimuserdeletedone:async e=>{await this.deprovision(e)}};const t=this.config;this.managerUrl=(t.cloudery_manager_url||"").replace(/\/$/,""),this.managerToken=t.cloudery_manager_token||"",this.offer=t.cloudery_offer||"b2b_twake_default",this.clouderyDomain=t.cloudery_domain||"",this.userBranch=t.cloudery_user_branch||"",this.orgIdHeader=(t.cloudery_org_id_header||"x-cloudery-org-id").toLowerCase(),this.orgRoleHeader=(t.cloudery_org_role_header||"x-cloudery-org-role").toLowerCase();const r=t.cloudery_default_org_role||"member",i=r.trim().toLowerCase();o.has(i)?this.defaultOrgRole=i:(this.defaultOrgRole="member",this.logger.warn(`${this.name}: cloudery_default_org_role "${r}" is not one of ${[...o].join(", ")} — using "member"`)),this.fqdnAttribute=t.cloudery_fqdn_attribute||"twakeWorkspaceUrl",this.orgIdAttribute=t.cloudery_org_id_attribute||"twakeOrganizationId",this.orgRoleAttribute=t.cloudery_org_role_attribute||"twakeOrganizationRole",this.phonesAttribute=t.cloudery_phones_attribute||"twakePhones",this.invitedAttribute=t.cloudery_invited_attribute||"twakeInvited",this.defaultLocale=t.cloudery_default_locale||"en",this.rdnAttribute=t.scim_user_rdn_attribute||"uid",this.authExchange=t.cozy_auth_exchange||"auth",this.b2bExchange=t.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=t.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=t.cozy_user_deleted_routing_key??"domain.user.deleted",this.pollIntervalMs=Number(t.cloudery_workflow_poll_interval_ms)||2e3,this.maxPollAttempts=Number(t.cloudery_workflow_max_attempts)||60,this.baseResolver=new a(this.config),this.managerUrl||this.logger.warn(`${this.name}: cloudery_manager_url is empty — instance provisioning will be skipped`),this.userBranch||this.logger.warn(`${this.name}: cloudery_user_branch is empty — no users will be provisioned`),this.logger.info({plugin:this.name,event:"init",managerUrl:this.managerUrl||void 0,userBranch:this.userBranch||void 0,orgIdHeader:this.orgIdHeader,orgRoleHeader:this.orgRoleHeader,defaultOrgRole:this.defaultOrgRole,fqdnAttribute:this.fqdnAttribute,orgIdAttribute:this.orgIdAttribute,orgRoleAttribute:this.orgRoleAttribute,phonesAttribute:this.phonesAttribute,invitedAttribute:this.invitedAttribute,pollIntervalMs:this.pollIntervalMs,maxPollAttempts:this.maxPollAttempts})}captureCreate(e,t){this.pruneStash();const r=this.extractId(e),i=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,result:"skipped",reason:"missing_configuration"});if(!r)return void this.logger.info({plugin:this.name,event:"captureCreate",base:i,result:"skipped",reason:"missing_user_name"});if(!this.isAtOrUnder(i,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,result:"skipped",reason:"outside_b2b_branch"});const s=this.readHeader(t,this.orgIdHeader);if(!s)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:`B2B branch create without ${this.orgIdHeader} — skipping provisioning`});const n=this.extractPrimaryEmail(e);if(!n)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:"B2B branch create without a primary email — skipping provisioning"});const a=n.trim(),o=this.resolveRole(t,r);this.createStash.set(a.toLowerCase(),{orgId:s.trim(),email:a,role:o,base:i,ts:Date.now()}),this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,orgId:s.trim(),email:a,role:o,result:"stashed"})}async provision(e){const t=this.extractId(e);if(this.logger.info({plugin:this.name,event:"provision",userName:t||void 0}),!t)return void this.logger.info({plugin:this.name,event:"provision",result:"skipped",reason:"missing_user_name"});const r=this.extractPrimaryEmail(e);if(!r)return void this.logger.info({plugin:this.name,event:"provision",userName:t,result:"skipped",reason:"missing_primary_email"});const n=r.trim().toLowerCase(),a=this.createStash.get(n);if(!a)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:r.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(n),this.logger.info({plugin:this.name,event:"provision",userName:t,email:a.email,orgId:a.orgId,base:a.base,role:a.role,result:"context_loaded"});const o=a.email,l=emailDomain(o),u=i(t+a.orgId);if(!u)return void this.logger.error({plugin:this.name,event:"provision",userName:t,result:"no_slug"});const h=this.extractPrimaryPhone(e),g=this.extractPhones(e),d=this.extractPublicName(e)||t,c={plugin:this.name,event:"provision",userName:t,orgId:a.orgId,slug:u},m=`${u}.${this.clouderyDomain}`.toLowerCase();let p=null;try{p=await this.findInstanceByFqdn(m)}catch(e){this.logger.warn({...c,result:"existence_lookup_failed",fqdn:m,error:`${e}`})}if(p&&(p.internal_email&&p.internal_email.toLowerCase()!==o.toLowerCase()||p.oidc&&p.oidc.toLowerCase()!==u))return void this.logger.error({...c,result:"slug_taken",fqdn:m,uuid:p._id});const f=p?._id??null;let w;if(f)this.logger.info({...c,result:"instance_exists",fqdn:m,uuid:f}),w=m;else{let t;try{this.logger.info({...c,result:"creating_instance",orgDomain:l,email:o,mobile:h||void 0}),t=await this.createInstance({email:o,publicName:d,locale:this.extractLocale(e),oidc:u,phone:h||"",slug:u,public_name:d,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:o,org_domain:l,org_id:a.orgId})}catch(e){return void this.logger.error({...c,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...c,result:"workflow_failed",workflow:t.workflow});w=t.fqdn}const v=`${this.rdnAttribute}=${s(t)},${a.base}`,b={[this.fqdnAttribute]:w,[this.orgIdAttribute]:a.orgId,[this.orgRoleAttribute]:a.role,[this.invitedAttribute]:"TRUE",cn:t};g.length>0&&(b[this.phonesAttribute]=JSON.stringify(g));try{this.logger.info({...c,result:"writing_fqdn",dn:v,hasPhones:g.length>0}),await this.server.ldap.modify(v,{replace:b}),this.logger.info({...c,result:"written_fqdn",dn:v})}catch(e){this.logger.error({...c,event:"writeFqdn",dn:v,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:l,orgId:a.orgId,fqdn:w,email:o,mobile:h}),this.logger.info({...c,result:"success",fqdn:w})}async captureDelete(e,t){const r=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"missing_configuration"});if(!this.isAtOrUnder(r,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"outside_b2b_branch"});const i=`${this.rdnAttribute}=${s(e)},${r}`;try{this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,result:"searching"});const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.fqdnAttribute,"mail"]},i),r=t.searchEntries?.[0],s=r?firstValue(r[this.fqdnAttribute]):void 0,n=s?s.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i,"").split("/")[0]:void 0;if(!n)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,result:"skipped",reason:"missing_fqdn"});const a=r?firstValue(r.mail):void 0,o=a?emailDomain(a):"";this.deleteStash.set(e,{fqdn:n,domain:o}),this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,fqdn:n,domain:o,result:"stashed"})}catch(t){this.logger.warn({plugin:this.name,event:"captureDelete",id:e,error:`${t}`})}}async deprovision(e){this.logger.info({plugin:this.name,event:"deprovision",id:e});const t=this.deleteStash.get(e);if(!t)return void this.logger.info({plugin:this.name,event:"deprovision",id:e,result:"skipped",reason:"no_stashed_context"});this.deleteStash.delete(e);const r={plugin:this.name,event:"deprovision",id:e,fqdn:t.fqdn};let i=!1;try{this.logger.info({...r,result:"resolving_uuid"});const e=(await this.findInstanceByFqdn(t.fqdn,"_id"))?._id;e?(this.logger.info({...r,result:"deleting_instance",uuid:e}),await this.deleteInstance(e),i=!0,this.logger.info({...r,result:"deleted",uuid:e})):this.logger.warn({...r,result:"not_found"})}catch(e){this.logger.error({...r,result:"error",error:`${e}`})}i&&await this.publishUserDeleted(t.fqdn,t.domain)}async createInstance(e){this.logger.info({plugin:this.name,event:"createInstance",url:`${this.managerUrl}/api/v1/instances`});const r=await t(`${this.managerUrl}/api/v1/instances`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.managerToken}`},body:JSON.stringify(e)});if(!r.ok){const e=(await r.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"createInstance",result:"error",http_status:r.status,http_status_text:r.statusText,body:e||void 0}),new Error(`Cloudery createInstance HTTP ${r.status}`)}const i=await r.json();return this.logger.info({plugin:this.name,event:"createInstance",result:"success",id:i.id,fqdn:i.fqdn,workflow:i.workflow}),i}async waitForWorkflow(r){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,maxPollAttempts:this.maxPollAttempts,pollIntervalMs:this.pollIntervalMs});for(let i=1;i<=this.maxPollAttempts;i++){try{const e=await t(`${this.managerUrl}/api/v1/workflows/${encodeURIComponent(r)}`,{headers:{Authorization:`Bearer ${this.managerToken}`}});if(e.ok){const t=await e.json();if(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,status:t.status}),"succeeded"===t.status)return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"success"}),!0;if("failed"===t.status||"error"===t.status)return this.logger.warn({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"failed",status:t.status}),!1}else{const t=(await e.text().catch(()=>"")).slice(0,l);this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"http_error",http_status:e.status,http_status_text:e.statusText,body:t||void 0})}}catch(e){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,error:`${e}`})}i<this.maxPollAttempts&&(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"retrying"}),await e(this.pollIntervalMs))}return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,result:"timeout",maxPollAttempts:this.maxPollAttempts}),!1}async findInstanceByFqdn(e,r){this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e});const i=new URL(`${this.managerUrl}/api/v2/instances`);i.searchParams.set("fqdn",e),r&&i.searchParams.set("only",r),i.searchParams.set("limit","1");const s=await t(i.toString(),{headers:{Authorization:`Bearer ${this.managerToken}`}});if(!s.ok){const t=(await s.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:"error",http_status:s.status,http_status_text:s.statusText,body:t||void 0}),new Error(`Cloudery searchInstances HTTP ${s.status}`)}const n=await s.json(),a=n.items?.[0]??null,o=a?._id;return this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:o?"found":"not_found",uuid:o||void 0}),a}async deleteInstance(e){const r=new URL(`${this.managerUrl}/api/v1/instances/${encodeURIComponent(e)}`);r.searchParams.set("user_request","true"),this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,url:r.toString()});const i=await t(r.toString(),{method:"DELETE",headers:{Authorization:`Bearer ${this.managerToken}`}});if(!i.ok){const t=(await i.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"deleteInstance",uuid:e,result:"error",http_status:i.status,http_status_text:i.statusText,body:t||void 0}),new Error(`Cloudery deleteInstance HTTP ${i.status}`)}this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,result:"success",http_status:i.status})}async publishUserCreated(e){const t=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,orgId:e.orgId,orgDomain:e.orgDomain,hasRabbitmq:Boolean(t)}),!t)return void this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,result:"skipped",reason:"missing_rabbitmq"});const r={twakeId:e.twakeId,domain:e.orgDomain,organizationDomain:e.orgDomain,workplaceFqdn:e.fqdn,organizationId:e.orgId};e.email&&(r.internalEmail=e.email),e.mobile&&(r.mobile=e.mobile);try{await t.publish(this.authExchange,this.userCreatedRoutingKey,r),this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,error:`${t}`})}}async publishUserDeleted(e,t){if(!this.userDeletedRoutingKey)return;const r=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,domain:t,hasRabbitmq:Boolean(r)}),r)try{await r.publish(this.b2bExchange,this.userDeletedRoutingKey,{workplaceFqdn:e,domain:t}),this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,error:`${t}`})}else this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,result:"skipped",reason:"missing_rabbitmq"})}readHeader(e,t){if(!e)return;const r=e.get?e.get(t):void 0;if("string"==typeof r&&r.length>0)return r;const i=e.query?.[t];return"string"==typeof i&&i.length>0?i:void 0}resolveRole(e,t){const r=this.readHeader(e,this.orgRoleHeader)?.trim().toLowerCase();return r?o.has(r)?(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"accepted",role:r}),r):(this.logger.warn({plugin:this.name,event:"captureCreate",userName:t,message:`Unknown ${this.orgRoleHeader} "${r}" — falling back to ${this.defaultOrgRole}`}),this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole):(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole)}isAtOrUnder(e,t){return e.trim().toLowerCase()===t.trim().toLowerCase()||n(e,t)}pruneStash(){const e=Date.now()-3e5;for(const[t,r]of this.createStash)r.ts<e&&this.createStash.delete(t)}normalizeUserName(e){"string"==typeof e.userName&&(e.userName=e.userName.toLowerCase())}extractId(e){return"string"==typeof e.userName&&e.userName.length>0?e.userName:"string"==typeof e.id&&e.id.length>0?e.id:null}extractPrimaryEmail(e){const t=e.emails;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPrimaryPhone(e){const t=e.phoneNumbers;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPhones(e){const t=e.phoneNumbers;return t&&0!==t.length?t.filter(e=>"string"==typeof e.value&&e.value.trim().length>0).map(e=>({number:e.value.trim(),primary:Boolean(e.primary)})):[]}extractPublicName(e){if("string"==typeof e.displayName&&e.displayName.trim().length>0)return e.displayName.trim();const t=e.name?.formatted;if("string"==typeof t&&t.trim().length>0)return t.trim();const r=`${e.name?.givenName?.trim()??""} ${e.name?.familyName?.trim()??""}`.trim();return r.length>0?r:null}extractLocale(e){return"string"==typeof e.locale&&e.locale.length>0?e.locale:"string"==typeof e.preferredLanguage&&e.preferredLanguage.length>0?e.preferredLanguage:this.defaultLocale}}function emailDomain(e){const t=e.lastIndexOf("@");return t>=0?e.slice(t+1):""}function firstValue(e){if(Array.isArray(e)){const t=e[0];return"string"==typeof t?t:void 0}return"string"==typeof e?e:void 0}export{ClouderyProvision as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{NotFoundError as e,BadRequestError as i}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r}from"../../lib/instanceProviders.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{rdnValue as o,asyncHandler as c,escapeLdapFilter as h}from"../../lib/utils.js";import{lifecycleAttributes as d,isTombstone as l}from"./lifecycleAttributes.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},u=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.offers=[],this.queues=new Map,this.hooks={ldapaddrequest:([t,e,i])=>{if(this.patterns.some(e=>e.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async([t,e])=>{const i=this.account(t,e);i&&await this.serialized(t,async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}})},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=/^uid=/i.test(i)?o(i):"";t&&a&&await this.serialized(i,async()=>{try{await e.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};const e=this.config,i=(e.plugin||[]).find(t=>u.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=(e.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of(e.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=d(e),this.fqdnAttribute=e.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=e.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=e.twake_instance_timeout||3e4;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user||"admin",e.twake_instance_cozy_passphrase||"",e.twake_instance_cozy_domain,e.twake_instance_cozy_context||"",e.twake_instance_cozy_apps||"",s),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=(e.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token||"",e.twake_instance_cloudery_domain,t.length>1?"":t[0],s),this.locale=e.twake_instance_locale||"en"}}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,c(async(t,n)=>{const r=a(t,n);if(!1===r)return;let s=r?.dn;if(void 0===s&&"string"==typeof r?.mail&&r.mail){const i=await this.findByMail(r.mail,t);if(!i)throw new e(`No account for ${r.mail}`);s=i.dn}if("string"!=typeof s||!s)throw new i("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){return this.serialized(t,async()=>{const n=await this.read(t,a);if(!n)throw new e(`No entry at ${t}`);const r=this.account(t,n);if(!r)throw new i(`${t} gets no instance`);if(first(this.value(n,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const s=first(this.value(n,this.fqdnAttribute));if(s){const e=s.toLowerCase()===this.provider.address(r)?{fqdn:s,ready:!0}:await this.provider.find(r,s);if(e?.ready)return await this.ready(t,n,r,e.fqdn,a),"ready"}return this.provide(t,n,r,a)})}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const i=await this.findOrganization(t.id);if(!i)throw new e(`No organization ${t.id}`);t.name&&!first(this.value(i,this.organizationName))&&(await this.server.ldap.modify(i.dn,{replace:{[this.organizationName]:t.name}}),i[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(i,this.organizationFqdn)),n=this.organizationRequest(t),s=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(i,this.sentAttribute)))return s?.ready?await this.organizationReady(i,s.fqdn):await this.releaseMembers(i.dn),"ready";let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(i,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer}}async requestOrganization(t){if(!(this.provider instanceof r))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const i=first(this.value(e,this.organizationName)),a=first(this.value(e,this.organizationDomain));if(!i||!a)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const n=this.organizationRequest({id:t,name:i,domain:a});if(!await this.provider.find(n)){await this.provider.createOrganization({id:t,name:i,domain:a});try{await this.provider.create(n)}catch(t){if(!await this.provider.find(n))throw t}}}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=e.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);n?await this.settle(n.dn,i):this.logger.debug(`${this.name}: no account here for ${e}`)}async settle(t,e){await this.serialized(t,async()=>{const i=await this.read(t),a=i&&this.account(t,i);i&&a&&(first(this.value(i,this.sentAttribute))||await this.ready(t,i,a,e))})}async provide(t,e,i,a){i.orgId&&await this.requestOrganization(i.orgId);const n=await this.provider.find(i),r=n?n.ready&&n.fqdn:await this.provider.create(i);return r?(await this.ready(t,e,i,r,a),"ready"):"pending"}async ready(t,e,i,a,n){if(first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(e,this.sentAttribute)))return;const s=this.provider instanceof r&&i.orgId&&await this.findOrganization(i.orgId);if(s&&!first(this.value(s,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${s.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${i.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${i.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n)}async organizationReady(t,e){const i=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof r&&await this.provider.linkOrganization(a,e),first(this.value(t,this.sentAttribute)))return this.releaseMembers(i);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(i,{replace:{[this.organizationFqdn]:e}});const n=first(this.value(t,this.organizationDomain)),s=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:e,...s?{organization:s}:{},...n?{domain:n}:{}}),await this.server.ldap.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(i)}async releaseMembers(t){const e=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let i=0;for(const t of e.searchEntries)try{await this.release(t)}catch(e){i++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(e)}`)}if(i)throw new Error(`${this.name}: ${i} member(s) of ${t} not released`)}async release(t){const e=t.dn,i=this.account(e,t),a=first(this.value(t,this.fqdnAttribute));if(!i||!a)return;const n=a.toLowerCase()===this.provider.address(i)?{fqdn:a,ready:!0}:await this.provider.find(i,a);if(n?.ready)return this.settle(e,n.fqdn);this.logger.warn(`${this.name}: ${e} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===e.toLowerCase()}async findOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${h(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const a=s(e);if(32===a)return;if(34===a)throw new i(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;if(l(e,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(e,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(e,"mail")),s=first(this.value(e,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=i.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=i.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(e=>e.test(t))];return{id:this.id(s,i),...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||s,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}}}match(t){return this.patterns.map(e=>e.exec(t)).find(t=>null!==t)}id(t,e){const i={uid:t,...e.groups};return(this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,e)=>i[e]??"").replace(/\./g,"").toLowerCase()}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${h(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.patterns.some(e=>e.test(t.dn)))}}export{TwakeInstances as default};
1
+ import t from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as e}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r,instanceSlug as s}from"../../lib/instanceProviders.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{rdnValue as c,asyncHandler as h,escapeLdapFilter as d}from"../../lib/utils.js";import{lifecycleAttributes as l,isTombstone as u}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},g=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.offers=[],this.queues=new Map,this.announcing=new Map,this.hooks={ldapaddrequest:([t,i,e])=>{if(this.patterns.some(i=>i.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const e of Object.keys(i))t.includes(e.toLowerCase())&&delete i[e]}return[t,i,e]},ldapaddafter:async([t,i])=>{const e=this.account(t,i);e&&(await this.serialized(t,async()=>{try{await this.provide(t,i,e)}catch(i){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(i)})}}),e.orgId&&await this.announcing.get(e.orgId.toLowerCase()))},ldapdeletedone:async t=>{const i=this.provider;if(i instanceof n)for(const e of Array.isArray(t)?t:[t]){const t=this.match(e),a=/^uid=/i.test(e)?c(e):"";t&&a&&await this.serialized(e,async()=>{try{await i.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:e,error:String(t)})}})}}};const i=this.config,e=(i.plugin||[]).find(t=>g.test(t));if(e)throw new Error(`${this.name}: ${e} gives accounts their instance too; load one of them`);this.patterns=(i.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,i]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(i);for(const[,t]of(i.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=l(i),this.fqdnAttribute=i.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=i.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=i.twake_instance_timeout||3e4;if("cozy-stack"===i.twake_instance_provider){if(!i.twake_instance_cozy_url||!i.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(i.twake_instance_cozy_url.replace(/\/$/,""),i.twake_instance_cozy_user||"admin",i.twake_instance_cozy_passphrase||"",i.twake_instance_cozy_domain,i.twake_instance_cozy_context||"",i.twake_instance_cozy_apps||"",s),this.locale=i.twake_instance_locale||"fr"}else{if("cloudery"!==i.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!i.twake_instance_cloudery_url||!i.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=(i.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,t.length>1?"":t[0],s),this.locale=i.twake_instance_locale||"en"}}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,h(async(t,n)=>{const r=a(t,n);if(!r)return;let s=r.dn;if(void 0===s&&"string"==typeof r.mail&&r.mail){const e=await this.findByMail(r.mail,t);if(!e)throw new i(`No account for ${r.mail}`);s=e.dn}if("string"!=typeof s||!s)throw new e("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){let n;const r=await this.serialized(t,async()=>{const r=await this.read(t,a);if(!r)throw new i(`No entry at ${t}`);const s=this.account(t,r);if(!s)throw new e(`${t} gets no instance`);if(n=s.orgId,first(this.value(r,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const o=first(this.value(r,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(s)?{fqdn:o,ready:!0}:await this.provider.find(s,o);if(i?.ready)return await this.ready(t,r,s,i.fqdn,a),"ready"}return this.provide(t,r,s,a)});return n&&await this.announcing.get(n.toLowerCase()),r}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),s=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return s?.ready?await this.organizationReady(e,s.fqdn):await this.releaseMembers(e.dn),"ready";let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer,organization:!0}}async requestOrganization(t,i){if(!(this.provider instanceof r))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const a=first(this.value(e,this.organizationName)),n=first(this.value(e,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const s=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(s);if(o){const r=t.toLowerCase();if(!o.ready||this.announcing.has(r))return;return i&&await this.server.ldap.modify(e.dn,{},i),void(this.announcing.has(r)||this.announcing.set(r,this.ensureOrganization({id:t,name:a,domain:n}).catch(i=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(i)})}).finally(()=>this.announcing.delete(r))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(s)}catch(t){if(!await this.provider.find(s))throw t}}async onWorkplaceCreated(t){const{internalEmail:i,workplaceFqdn:e}=t;if(!i||!e)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=i.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,e)})}const n=await this.findByMail(i);n?await this.settle(n.dn,e):this.logger.debug(`${this.name}: no account here for ${i}`)}async settle(t,i){await this.serialized(t,async()=>{const e=await this.read(t),a=e&&this.account(t,e);e&&a&&(first(this.value(e,this.sentAttribute))||await this.ready(t,e,a,i))})}async provide(t,i,e,a){e.orgId&&await this.requestOrganization(e.orgId,a);const n=await this.provider.find(e),r=n?n.ready&&n.fqdn:await this.provider.create(e);return r?(await this.ready(t,i,e,r,a),"ready"):"pending"}async ready(t,i,e,a,n){if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(i,this.sentAttribute)))return;const s=this.provider instanceof r&&e.orgId&&await this.findOrganization(e.orgId);if(s&&!first(this.value(s,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${s.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${e.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${e.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:e.twakeId,internalEmail:e.email,workplaceFqdn:a,...e.orgId?{organizationId:e.orgId}:{},...e.orgDomain?{domain:e.orgDomain,organizationDomain:e.orgDomain}:{},...e.phone?{mobile:e.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n)}async organizationReady(t,i){const e=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof r&&await this.provider.linkOrganization(a,i),first(this.value(t,this.sentAttribute)))return this.releaseMembers(e);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(e,{replace:{[this.organizationFqdn]:i}});const n=first(this.value(t,this.organizationDomain)),s=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:i,...s?{organization:s}:{},...n?{domain:n}:{}}),await this.server.ldap.modify(e,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(e)}async releaseMembers(t){const i=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let e=0;for(const t of i.searchEntries)try{await this.release(t)}catch(i){e++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(i)}`)}if(e)throw new Error(`${this.name}: ${e} member(s) of ${t} not released`)}async release(t){const i=t.dn,e=this.account(i,t),a=first(this.value(t,this.fqdnAttribute));if(!e||!a)return;const n=a.toLowerCase()===this.provider.address(e)?{fqdn:a,ready:!0}:await this.provider.find(e,a);if(n?.ready)return this.settle(i,n.fqdn);this.logger.warn(`${this.name}: ${i} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,i){const e=t.toLowerCase(),a=(this.queues.get(e)??Promise.resolve()).catch(()=>{}).then(i),n=a.catch(()=>{});return this.queues.set(e,n),n.then(()=>{this.queues.get(e)===n&&this.queues.delete(e)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,i){return`${t}@${i}`.toLowerCase()}isOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===i.toLowerCase()}async findOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${d(t)})`},i)).searchEntries[0]}async read(t,i){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,i)).searchEntries[0]}catch(i){const a=o(i);if(32===a)return;if(34===a)throw new e(`Invalid DN: ${t}`);throw i}}async announce(t,i,e){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${i} not sent`);await a.publish(t,i,e),this.logger.info({plugin:this.name,event:i,exchange:t})}account(t,i){const e=this.match(t);if(!e)return;if(u(i,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(i,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(i,"mail")),s=first(this.value(i,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=e.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=e.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(i=>i.test(t))],l=this.id(s,e);if(l)return{id:l,...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(i,"displayName"))||first(this.value(i,"cn"))||s,locale:first(this.value(i,"preferredLanguage"))||this.locale,phone:first(this.value(i,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return s((this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??""))}value(t,i){const e=i.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===e);return a?t[a]:void 0}async findByMail(t,i){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${d(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.test(t.dn)))}}export{TwakeInstances as default};
@@ -11,13 +11,18 @@ export declare class GroupApiClient {
11
11
  }>;
12
12
  getGroups(orgDn: string): Promise<LdapGroup[]>;
13
13
  getGroup(dn: string): Promise<LdapGroup>;
14
- updateGroup(dn: string, data: Partial<LdapGroup>): Promise<void>;
14
+ /**
15
+ * Replace attributes of an entry.
16
+ *
17
+ * @param dn entry to change
18
+ * @param replace attributes to replace; see `changedAttributes`
19
+ */
20
+ updateGroup(dn: string, replace: Record<string, unknown>): Promise<void>;
15
21
  createEntry(dn: string, data: Record<string, unknown>): Promise<void>;
16
22
  deleteEntry(dn: string): Promise<void>;
23
+ /** Add members through the route that checks them */
24
+ addMembers(dn: string, member: string[]): Promise<void>;
25
+ removeMember(dn: string, member: string): Promise<void>;
17
26
  moveGroup(cn: string, targetOrgDn: string): Promise<void>;
18
27
  renameGroup(cn: string, newCn: string): Promise<void>;
19
- getPointerOptions(branch: string): Promise<Array<{
20
- dn: string;
21
- label: string;
22
- }>>;
23
28
  }
@@ -19,9 +19,17 @@ export interface Config {
19
19
  schemaUrl?: string;
20
20
  schema?: SchemaDefinition;
21
21
  };
22
+ ldapOrganizations?: {
23
+ linkAttribute?: string;
24
+ pathAttribute?: string;
25
+ };
22
26
  };
23
27
  [key: string]: unknown;
24
28
  }
29
+ /** An entry as the API returns it */
30
+ export type Entry = {
31
+ dn: string;
32
+ } & Record<string, unknown>;
25
33
  export interface ResourceConfig {
26
34
  name: string;
27
35
  pluralName?: string;
@@ -22,7 +22,13 @@ export declare class ResourceApiClient {
22
22
  getSchema(schemaUrl: string): Promise<Schema>;
23
23
  getResources(search?: string): Promise<LdapResource[]>;
24
24
  getResource(dn: string): Promise<LdapResource>;
25
- updateResource(dn: string, data: Partial<LdapResource>): Promise<LdapResource>;
25
+ /**
26
+ * Replace attributes of an entry.
27
+ *
28
+ * @param dn entry to change
29
+ * @param replace attributes to replace; see `changedAttributes`
30
+ */
31
+ updateResource(dn: string, replace: Record<string, unknown>): Promise<LdapResource>;
26
32
  createResource(data: Partial<LdapResource>): Promise<LdapResource>;
27
33
  deleteResource(dn: string): Promise<void>;
28
34
  /**
@@ -10,13 +10,15 @@ export declare class UnitApiClient {
10
10
  dn: string;
11
11
  }>;
12
12
  getUnit(dn: string): Promise<LdapUnit>;
13
- updateUnit(dn: string, data: Partial<LdapUnit>): Promise<void>;
13
+ /**
14
+ * Replace attributes of an entry.
15
+ *
16
+ * @param dn entry to change
17
+ * @param replace attributes to replace; see `changedAttributes`
18
+ */
19
+ updateUnit(dn: string, replace: Record<string, unknown>): Promise<void>;
14
20
  createEntry(dn: string, data: Record<string, unknown>): Promise<void>;
15
21
  deleteEntry(dn: string): Promise<void>;
16
- getPointerOptions(branch: string): Promise<Array<{
17
- dn: string;
18
- label: string;
19
- }>>;
20
22
  moveUnit(dn: string, targetOrgDn: string): Promise<{
21
23
  success: boolean;
22
24
  newDn?: string;
@@ -25,6 +25,8 @@ export declare class LdapUserEditor {
25
25
  destroy(): void;
26
26
  createUser(userData: Partial<LdapUser>): Promise<LdapUser>;
27
27
  deleteUser(dn: string): Promise<void>;
28
+ /** Clear the selection and refresh the list once `dn` is deleted */
29
+ private userDeleted;
28
30
  getConfig(): Config | null;
29
31
  getApi(): UserApiClient;
30
32
  getCurrentOrgDn(): string | null;
@@ -20,7 +20,13 @@ export declare class UserApiClient {
20
20
  getSchema(schemaUrl: string): Promise<Schema>;
21
21
  getUsers(search?: string): Promise<LdapUser[]>;
22
22
  getUser(dn: string): Promise<LdapUser>;
23
- updateUser(dn: string, data: Partial<LdapUser>): Promise<LdapUser>;
23
+ /**
24
+ * Replace attributes of an entry.
25
+ *
26
+ * @param dn entry to change
27
+ * @param replace attributes to replace; see `changedAttributes`
28
+ */
29
+ updateUser(dn: string, replace: Record<string, unknown>): Promise<LdapUser>;
24
30
  createUser(data: Partial<LdapUser>): Promise<LdapUser>;
25
31
  deleteUser(dn: string): Promise<void>;
26
32
  moveUser(dn: string, targetOrgDn: string): Promise<{
@@ -26,3 +26,33 @@ export declare function collectPointerArrayValues(container: HTMLElement): Recor
26
26
  * Group schema fields by category
27
27
  */
28
28
  export declare function groupSchemaFields(schema: Record<string, SchemaAttribute>, categorizer?: (fieldName: string, attribute: SchemaAttribute) => string | null): Map<string, [string, SchemaAttribute][]>;
29
+ /**
30
+ * A value as the list it stands for: the API gives an attribute holding one
31
+ * value as that value, not as a one-element list.
32
+ *
33
+ * @param value attribute value as the API gives it
34
+ * @returns its values as text
35
+ */
36
+ export declare const listValues: (value: unknown) => string[];
37
+ /**
38
+ * The values to add and remove to go from `before` to `after`.
39
+ *
40
+ * @param before values as loaded
41
+ * @param after values as edited
42
+ * @returns what was added, and what was removed
43
+ */
44
+ export declare function listChanges(before: unknown, after: unknown): {
45
+ added: string[];
46
+ removed: string[];
47
+ };
48
+ /**
49
+ * The attributes of `edited` whose value differs from `original`, as the
50
+ * `replace` of a modify request: the routes take only `add`, `replace` and
51
+ * `delete`, and refuse a computed attribute even sent unchanged. One emptied
52
+ * in the form is replaced with no value, which clears it.
53
+ *
54
+ * @param original entry as loaded
55
+ * @param edited attributes as the form holds them
56
+ * @returns the attributes to replace, empty when nothing changed
57
+ */
58
+ export declare function changedAttributes(original: Record<string, unknown> | null | undefined, edited: Record<string, unknown>): Record<string, unknown>;
@@ -1,4 +1,4 @@
1
- import type { AttributeValue } from '../lib/ldapActions';
1
+ import type { AttributesList, AttributeValue, ModifyRequest } from '../lib/ldapActions';
2
2
  /**
3
3
  * Scalar and container types an entity schema may declare.
4
4
  *
@@ -317,6 +317,35 @@ export declare function modifiedAttributeNames(body: {
317
317
  replace?: object;
318
318
  delete?: string[] | object;
319
319
  }): string[];
320
+ /**
321
+ * The text the directory stores for a JSON value. ldapts throws on anything
322
+ * but a string or a Buffer, which reached the client as a 500: a number is
323
+ * written as text where the schema declares one, a boolean as LDAP's
324
+ * `TRUE` / `FALSE`, and anything else is refused.
325
+ *
326
+ * @param schema schema of the entry, if any
327
+ * @param field attribute name
328
+ * @param value value as the client sent it
329
+ * @returns the value to write
330
+ * @throws BadRequestError when the value cannot be written as text
331
+ */
332
+ export declare function toDirectoryValue(schema: Schema | undefined, field: string, value: AttributeValue): AttributeValue;
333
+ /**
334
+ * {@link toDirectoryValue} on every value of an entry about to be created,
335
+ * in place.
336
+ *
337
+ * @param schema schema of the entry, if any
338
+ * @param entry attributes sent by the client
339
+ */
340
+ export declare function toDirectoryEntry(schema: Schema | undefined, entry: AttributesList): void;
341
+ /**
342
+ * {@link toDirectoryValue} on every value a modify request carries, in place:
343
+ * added, replaced, and deleted when `delete` names values.
344
+ *
345
+ * @param schema schema of the entry, if any
346
+ * @param changes modify request
347
+ */
348
+ export declare function toDirectoryChanges(schema: Schema | undefined, changes: ModifyRequest): void;
320
349
  /**
321
350
  * Lowercased names of the attributes marked `neverReturn` in any of the given
322
351
  * schemas.
@@ -6,6 +6,17 @@
6
6
  * admin API, which builds it before answering.
7
7
  */
8
8
  import { type RequestInit, type Response } from 'node-fetch';
9
+ /**
10
+ * Reduce a name to what the Cloudery takes in a slug; it refused `_` or `@`
11
+ * with "Slug is invalid". `-` is kept, except at either end where no host
12
+ * name takes it: an instance already created with one would otherwise change
13
+ * address. Distinct uids can reduce to one slug, so a creation first checks
14
+ * that an instance already there is the account's own.
15
+ *
16
+ * @param value uid, or the slug template filled in
17
+ * @returns the slug, empty when nothing of `value` is kept
18
+ */
19
+ export declare const instanceSlug: (value: string) => string;
9
20
  export interface InstanceRequest {
10
21
  /** Slug of the instance, and its OIDC id unless `oidc` says otherwise */
11
22
  id: string;
@@ -17,6 +28,12 @@ export interface InstanceRequest {
17
28
  orgId?: string;
18
29
  orgDomain?: string;
19
30
  offer?: string;
31
+ /**
32
+ * An organization's own instance. It may be owned at the platform domain,
33
+ * as a personal account's is; with the Cloudery, the organization's link
34
+ * tells them apart
35
+ */
36
+ organization?: boolean;
20
37
  }
21
38
  export interface FoundInstance {
22
39
  fqdn: string;
@@ -58,6 +75,8 @@ export declare class ClouderyProvider extends HttpProvider implements InstancePr
58
75
  private headers;
59
76
  create(request: InstanceRequest): Promise<undefined>;
60
77
  find(request: InstanceRequest, at?: string): Promise<FoundInstance | undefined>;
78
+ /** Whether the Cloudery holds this instance as the organization's own */
79
+ private organizationHolds;
61
80
  createOrganization(org: {
62
81
  id: string;
63
82
  name: string;
@@ -36,6 +36,15 @@ export interface ModifyRequest {
36
36
  replace?: AttributesList;
37
37
  delete?: string[] | AttributesList;
38
38
  }
39
+ /**
40
+ * Refuse a modify body a client got wrong: any other key was ignored, and a
41
+ * body naming no attribute in `add`, `replace` or `delete` answered success
42
+ * without changing anything.
43
+ *
44
+ * @param body parsed request body
45
+ * @throws BadRequestError naming what is wrong
46
+ */
47
+ export declare function assertModifyRequest(body: object): asserts body is ModifyRequest;
39
48
  export interface ModifyOptions {
40
49
  /**
41
50
  * Send the permissive modify control, not critical: a directory that
@@ -51,6 +51,8 @@ export default class LdapAccountLifecycle extends DmPlugin {
51
51
  * @param entity flat entity holding the account
52
52
  * @param id identifier or DN of the entry
53
53
  * @param changes modification to apply
54
+ * @param req request this comes from: without it, every authorization
55
+ * plugin built on `AuthzBase` skips its branch check
54
56
  */
55
57
  private modify;
56
58
  }
@@ -52,7 +52,13 @@ export default class ClouderyProvision extends DmPlugin {
52
52
  private createInstance;
53
53
  /** Poll the workflow until it succeeds. Returns false on failure/timeout. */
54
54
  private waitForWorkflow;
55
- private findInstanceUuidByFqdn;
55
+ /**
56
+ * The instance at this FQDN, if any.
57
+ *
58
+ * @param fqdn instance address
59
+ * @param only the one field to ask for, when the others are not needed
60
+ */
61
+ private findInstanceByFqdn;
56
62
  private deleteInstance;
57
63
  private publishUserCreated;
58
64
  private publishUserDeleted;
@@ -39,6 +39,8 @@ export default class TwakeInstances extends DmPlugin {
39
39
  private readonly offers;
40
40
  /** Work on one account, or organization, waits for the work before it */
41
41
  private readonly queues;
42
+ /** Organizations a member's add is announcing, by lowercased id */
43
+ private readonly announcing;
42
44
  constructor(server: DM);
43
45
  hooks: Hooks;
44
46
  /**
@@ -66,8 +68,10 @@ export default class TwakeInstances extends DmPlugin {
66
68
  /**
67
69
  * The Cloudery builds no member instance for an organization it does not
68
70
  * have, so a member asks for a missing one first. The organization is
69
- * marked when its own workplace.created arrives. Not serialized on the
70
- * organization: its work releases members, which would wait on this one.
71
+ * marked when its own workplace.created arrives, or announced here when its
72
+ * instance is already built: on a caller's request, only when that caller
73
+ * may write the organization. Not serialized on the organization: its work
74
+ * releases members, which would wait on this one.
71
75
  */
72
76
  private requestOrganization;
73
77
  onWorkplaceCreated(message: WorkplaceCreated): Promise<void>;
@@ -1183,6 +1183,15 @@ move names both DNs:
1183
1183
  }
1184
1184
  ```
1185
1185
 
1186
+ **DN that cannot be parsed** — an organization read or a move target that is
1187
+ not a DN (LDAP result code 34):
1188
+
1189
+ ```json
1190
+ {
1191
+ "error": "garbage is not a valid DN"
1192
+ }
1193
+ ```
1194
+
1186
1195
  #### 401 Unauthorized
1187
1196
 
1188
1197
  **Missing Authentication:**
@@ -1227,6 +1236,16 @@ move names both DNs:
1227
1236
  }
1228
1237
  ```
1229
1238
 
1239
+ **Entry or value the directory does not hold** — a modify or a delete of an
1240
+ entry that is not there, or the removal of a value the entry lacks (LDAP
1241
+ result codes 32 and 16). Removing a group member that is not one names both:
1242
+
1243
+ ```json
1244
+ {
1245
+ "error": "uid=alice,ou=users,dc=example,dc=com is not a member of cn=backend-guild,ou=groups,dc=example,dc=com"
1246
+ }
1247
+ ```
1248
+
1230
1249
  #### 409 Conflict
1231
1250
 
1232
1251
  **Duplicate Entry:**
@@ -97,6 +97,15 @@ organization and the organization's instance when the Cloudery has neither,
97
97
  so making sure of a member also repairs an organization whose creation
98
98
  failed.
99
99
 
100
+ An organization created before 0.13.0 has a built instance but was never
101
+ announced, and that instance is owned at the platform domain rather than
102
+ `<id>@<domain>`, as a personal account's is. The plugin takes it as the
103
+ organization's only when the Cloudery organization is linked to it, and
104
+ announces the organization when one of its members is added or made sure of.
105
+ Announcing writes the organization with the plugin's rights, so making sure
106
+ of a member through the API announces it only for a caller who may write the
107
+ organization entry, and is refused with a `403` otherwise.
108
+
100
109
  ## Providers
101
110
 
102
111
  - `cloudery`: the Cloudery accepts the request and builds the instance
@@ -113,11 +122,15 @@ failed.
113
122
  already there is taken only when its email and OIDC id are this account's.
114
123
 
115
124
  The instance id is `--twake-instance-id`, a template of the account's `uid`
116
- and the named groups of the matching DN expression, for example
117
- `{uid}{org}`; dots are dropped. A name no expression captures stops the
118
- server at startup. It is the slug of the instance's address; the OIDC id is
119
- this slug with the Cloudery, and the `uid` with cozy-stack. `user.created`
120
- names the account by its `uid` (`twakeId`). A group named `org` is the organization id sent to the provider
125
+ and the named groups of the matching DN expression, for example `{uid}{org}`.
126
+ It is lowercased and reduced to `[a-z0-9-]`: the Cloudery refuses other
127
+ characters in a slug, such as `_` or `@`, so `jane.doe_stg` gives
128
+ `janedoestg`. Two `uid`s that reduce to the same slug cannot both have an
129
+ instance: the second is refused, as an instance already there that is not its
130
+ own. A name no expression captures stops the server at startup. It is the slug
131
+ of the instance's address; the OIDC id is this slug with the Cloudery, and the
132
+ `uid` with cozy-stack. `user.created` names the account by its `uid`
133
+ (`twakeId`). A group named `org` is the organization id sent to the provider
121
134
  and in `user.created`, with the mail's domain as its domain; with cozy-stack
122
135
  and no such group, `--twake-instance-cozy-org-id` and
123
136
  `--twake-instance-cozy-org-domain` stand for them.
@@ -99,7 +99,13 @@ It used to be taken as an absent one, so `{"password": ""}` answered
99
99
  that emptied the field by accident believed it had set the password it typed.
100
100
  Omitting the field is how a generated password is asked for.
101
101
 
102
- `forceChange` (true by default) also writes the `passwordReset`-role attribute
102
+ A password longer than 256 characters, or holding a control character (a NUL
103
+ byte, a newline…), is refused too: nobody can type it at a login prompt. The
104
+ endpoint applies no minimum length or complexity rule of its own. These rules
105
+ belong to this endpoint: a password written as an attribute, through
106
+ `PUT /api/v1/ldap/{pluralName}/{id}` or a creation, is not checked.
107
+
108
+ `forceChange` (a boolean, true by default) also writes the `passwordReset`-role attribute
103
109
  so the directory asks for a new password at next login. For a boolean
104
110
  attribute the values written are `TRUE` and `FALSE`; a directory that spells
105
111
  them otherwise says so through `states.required` and `states.cleared`.
@@ -120,6 +126,8 @@ them otherwise says so through `states.required` and `states.cleared`.
120
126
  | ------ | -------------------------------------------------------------- |
121
127
  | `400` | Unknown state, or a body with no `state` |
122
128
  | `400` | A `password` that is present but empty, `null` or not a string |
129
+ | `400` | A `password` over 256 characters or with a control character |
130
+ | `400` | A `forceChange` that is not a boolean |
123
131
  | `404` | No such account |
124
132
 
125
133
  ## See also
@@ -4,6 +4,48 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.20.3
8
+
9
+ ### Members whose uid the Cloudery refused as a slug
10
+
11
+ **Who is affected:** a deployment that loads `twake/instances` with the
12
+ Cloudery, whose members were created through SCIM or the API with a `uid`
13
+ holding characters such as `_` or `@`.
14
+
15
+ Those members got no instance: the Cloudery refused their slug. Once
16
+ deployed, find them under each organization entry with
17
+
18
+ ```
19
+ (&(uid=*)(mail=*)(!(twakeWorkspaceUrl=*)))
20
+ ```
21
+
22
+ and call `POST /api/v1/twake/instances/ensure` with `{ "dn": … }` for each.
23
+ Two members whose `uid`s reduce to the same slug cannot both have an
24
+ instance: the second call answers `409`,
25
+ [details](plugins/integrations/instances.md#providers).
26
+
27
+ ## To 0.20.2
28
+
29
+ ### Members of organizations created before 0.13.0
30
+
31
+ **Who is affected:** a deployment that loads `twake/instances` with the
32
+ Cloudery and has organizations created before 0.13.0 that got members since.
33
+
34
+ Those members have neither an address (`twakeWorkspaceUrl`, or
35
+ `--twake-instance-fqdn-attribute`) nor the mark (`twakeCreatedEventAt`, or
36
+ `--twake-instance-sent-attribute`), and adding another member does not
37
+ release them. Once deployed, find them under each organization entry with
38
+
39
+ ```
40
+ (&(uid=*)(mail=*)(!(twakeWorkspaceUrl=*))(!(twakeCreatedEventAt=*)))
41
+ ```
42
+
43
+ and call `POST /api/v1/twake/instances/ensure` with `{ "dn": … }` for each,
44
+ as a caller who may write the organization entry. The first call announces
45
+ the organization. Each call asks for the member's instance, and the member's
46
+ `user.created` follows once it is built,
47
+ [details](plugins/integrations/instances.md#organizations).
48
+
7
49
  ## To 0.20.0
8
50
 
9
51
  ### Team mailboxes keep their members to their domain
@@ -116,9 +116,13 @@ npm start -- --plugin core/ldap/raw
116
116
  ```bash
117
117
  npm start -- \
118
118
  --plugin core/auth/totp \
119
- --auth-totp "JBSWY3DPEHPK3PXP:admin:6"
119
+ --auth-totp "JBSWY3DPEHPK3PXP:admin:6" \
120
+ --auth-path-prefix /api
120
121
  ```
121
122
 
123
+ `--auth-path-prefix /api` leaves the example pages themselves reachable
124
+ without a code.
125
+
122
126
  3. **Open examples in browser:**
123
127
  ```
124
128
  http://localhost:8081/static/examples/web/totp-client.html