From f987a69686580dbcc1e34e5b0e67486e540dc742 Mon Sep 17 00:00:00 2001 From: f-w Date: Mon, 13 May 2024 19:27:43 +0000 Subject: [PATCH] =?UTF-8?q?Deploying=20to=20gh-pages=20from=20=20@=20825fc?= =?UTF-8?q?05c541c2de4b267f328fe0fb20faea3b5c9=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- preview/404.html | 33 ++++ preview/assets/404.html-6024058c.js | 1 + preview/assets/404.html-60b35caa.js | 1 + preview/assets/app-73097456.js | 10 + preview/assets/back-to-top-8efcbe56.svg | 1 + preview/assets/docsearch-1d421ddb.js | 2 + .../assets/filterQueryParam.html-428a3252.js | 29 +++ .../assets/filterQueryParam.html-d51cd871.js | 1 + .../filterQueryParamCode.html-18ac53c8.js | 1 + .../filterQueryParamCode.html-293978be.js | 1 + .../filterQueryParamExample.html-95d3a481.js | 11 ++ .../filterQueryParamExample.html-c90c301f.js | 1 + preview/assets/index-5161ad19.js | 17 ++ preview/assets/index.html-06effaf7.js | 6 + preview/assets/index.html-09f857ca.js | 1 + preview/assets/index.html-15fde261.js | 1 + preview/assets/index.html-1ae75d86.js | 63 +++++++ preview/assets/index.html-1d68b11c.js | 110 +++++++++++ preview/assets/index.html-1d87c569.js | 1 + preview/assets/index.html-233fe616.js | 4 + preview/assets/index.html-27f35336.js | 142 ++++++++++++++ preview/assets/index.html-284021a2.js | 1 + preview/assets/index.html-2fb78bd6.js | 1 + preview/assets/index.html-341d16d7.js | 12 ++ preview/assets/index.html-34c15120.js | 1 + preview/assets/index.html-36d5ffa2.js | 91 +++++++++ preview/assets/index.html-3a8f0d9c.js | 1 + preview/assets/index.html-3bc7b1c6.js | 1 + preview/assets/index.html-40029d88.js | 60 ++++++ preview/assets/index.html-42466a4a.js | 1 + preview/assets/index.html-43ad52c1.js | 3 + preview/assets/index.html-47b50680.js | 17 ++ preview/assets/index.html-4a529ad3.js | 47 +++++ preview/assets/index.html-4edd1034.js | 1 + preview/assets/index.html-4f4b887a.js | 1 + preview/assets/index.html-549ff1f9.js | 1 + preview/assets/index.html-568e376b.js | 1 + preview/assets/index.html-585cce7b.js | 1 + preview/assets/index.html-59271dfc.js | 1 + preview/assets/index.html-5afa062f.js | 74 ++++++++ preview/assets/index.html-5b46e0a4.js | 1 + preview/assets/index.html-602c2540.js | 1 + preview/assets/index.html-671e4912.js | 72 ++++++++ preview/assets/index.html-692e45cb.js | 1 + preview/assets/index.html-6b858d80.js | 1 + preview/assets/index.html-6bb621c5.js | 1 + preview/assets/index.html-6ca7c7e1.js | 1 + preview/assets/index.html-6e427c03.js | 7 + preview/assets/index.html-6e94f439.js | 1 + preview/assets/index.html-70c61dd5.js | 1 + preview/assets/index.html-76d114a5.js | 1 + preview/assets/index.html-797fb107.js | 1 + preview/assets/index.html-7af3bcd3.js | 95 ++++++++++ preview/assets/index.html-7b20a00b.js | 1 + preview/assets/index.html-7d2ae716.js | 1 + preview/assets/index.html-7dc4890d.js | 1 + preview/assets/index.html-7f453789.js | 4 + preview/assets/index.html-854328d1.js | 31 ++++ preview/assets/index.html-88fcd172.js | 1 + preview/assets/index.html-8a07e160.js | 8 + preview/assets/index.html-8ad94fae.js | 14 ++ preview/assets/index.html-8f681a20.js | 4 + preview/assets/index.html-9094b141.js | 1 + preview/assets/index.html-93ff6c80.js | 1 + preview/assets/index.html-94f1d832.js | 1 + preview/assets/index.html-963a4ecf.js | 1 + preview/assets/index.html-a08056c0.js | 6 + preview/assets/index.html-a0e9a4ad.js | 1 + preview/assets/index.html-a223bf40.js | 1 + preview/assets/index.html-a2d4ab5c.js | 1 + preview/assets/index.html-a48fd168.js | 1 + preview/assets/index.html-a70b6cf1.js | 1 + preview/assets/index.html-a7337040.js | 1 + preview/assets/index.html-a9eb55a1.js | 1 + preview/assets/index.html-b4c829a8.js | 1 + preview/assets/index.html-bc520f9b.js | 1 + preview/assets/index.html-bca8604a.js | 3 + preview/assets/index.html-c46be575.js | 1 + preview/assets/index.html-c67caa0a.js | 1 + preview/assets/index.html-caa38aac.js | 1 + preview/assets/index.html-ccd9b97c.js | 1 + preview/assets/index.html-ced62edd.js | 1 + preview/assets/index.html-d1326e32.js | 30 +++ preview/assets/index.html-ddb984f8.js | 34 ++++ preview/assets/index.html-e1863c04.js | 1 + preview/assets/index.html-e44840e5.js | 115 ++++++++++++ preview/assets/index.html-e67b9cd4.js | 16 ++ preview/assets/index.html-f8b7a659.js | 1 + preview/assets/index.html-f9768eb1.js | 1 + preview/assets/index.html-fae03536.js | 1 + preview/assets/index.html-fd34968b.js | 83 +++++++++ .../assets/jmespathFilter.html-35e45519.js | 18 ++ .../assets/jmespathFilter.html-7c48b0c1.js | 1 + preview/assets/style-8fbc3cfb.css | 1 + preview/assets/style-e9220a04.js | 1 + preview/assets/throttle.html-32dcc936.js | 1 + preview/assets/throttle.html-4a67c62e.js | 1 + .../assets/whereQueryParam.html-10f3d62d.js | 1 + .../assets/whereQueryParam.html-889e420d.js | 10 + .../whereQueryParamCode.html-33176c09.js | 1 + .../whereQueryParamCode.html-84587f03.js | 1 + .../whereQueryParamExample.html-10f69adc.js | 9 + .../whereQueryParamExample.html-68248e1c.js | 1 + preview/attachments/benchmark-email.txt | 25 +++ preview/docs/acknowledgments/index.html | 33 ++++ preview/docs/api-administrator/index.html | 142 ++++++++++++++ preview/docs/api-bounce/index.html | 33 ++++ preview/docs/api-config/index.html | 66 +++++++ preview/docs/api-notification/index.html | 92 +++++++++ preview/docs/api-overview/index.html | 33 ++++ preview/docs/api-subscription/index.html | 115 ++++++++++++ preview/docs/benchmarks/index.html | 79 ++++++++ preview/docs/bulk-import/index.html | 44 +++++ preview/docs/conduct/index.html | 33 ++++ preview/docs/config-adminIpList/index.html | 39 ++++ preview/docs/config-certificates/index.html | 48 +++++ preview/docs/config-cronJobs/index.html | 95 ++++++++++ preview/docs/config-database/index.html | 38 ++++ preview/docs/config-email/index.html | 123 +++++++++++++ preview/docs/config-httpHost/index.html | 36 ++++ .../docs/config-internalHttpHost/index.html | 36 ++++ preview/docs/config-middleware/index.html | 63 +++++++ preview/docs/config-nodeRoles/index.html | 33 ++++ preview/docs/config-notification/index.html | 104 +++++++++++ preview/docs/config-oidc/index.html | 49 +++++ preview/docs/config-overview/index.html | 33 ++++ .../config-reverseProxyIpLists/index.html | 40 ++++ preview/docs/config-rsaKeys/index.html | 46 +++++ preview/docs/config-sms/index.html | 106 +++++++++++ preview/docs/config-subscription/index.html | 174 ++++++++++++++++++ .../docs/config-workerProcessCount/index.html | 33 ++++ preview/docs/developer-notes/index.html | 35 ++++ preview/docs/health-check/index.html | 62 +++++++ preview/docs/index.html | 33 ++++ preview/docs/installation/index.html | 147 +++++++++++++++ preview/docs/memory-dump/index.html | 35 ++++ preview/docs/overview/index.html | 33 ++++ preview/docs/quickstart/index.html | 38 ++++ preview/docs/shared/filterQueryParam.html | 61 ++++++ preview/docs/shared/filterQueryParamCode.html | 33 ++++ .../docs/shared/filterQueryParamExample.html | 43 +++++ preview/docs/shared/jmespathFilter.html | 50 +++++ preview/docs/shared/throttle.html | 33 ++++ preview/docs/shared/whereQueryParam.html | 42 +++++ preview/docs/shared/whereQueryParamCode.html | 33 ++++ .../docs/shared/whereQueryParamExample.html | 41 +++++ preview/docs/upgrade/index.html | 127 +++++++++++++ preview/docs/web-console/index.html | 36 ++++ preview/docs/what's-new/index.html | 33 ++++ preview/drawings/subscription sequence.vsdx | Bin 0 -> 83584 bytes preview/favicon.ico | Bin 0 -> 22486 bytes preview/google5b66eb7c005753e3.html | 1 + preview/help/index.html | 33 ++++ preview/img/admin-data-models.svg | 3 + preview/img/architecture.svg | 3 + preview/img/list-unsubscription.png | Bin 0 -> 13590 bytes preview/img/logo.svg | 9 + .../subscription-multi-service-provider.png | Bin 0 -> 28412 bytes .../subscription-single-service-provider.png | Bin 0 -> 13518 bytes preview/index.html | 54 ++++++ 160 files changed, 4166 insertions(+) create mode 100644 preview/404.html create mode 100644 preview/assets/404.html-6024058c.js create mode 100644 preview/assets/404.html-60b35caa.js create mode 100644 preview/assets/app-73097456.js create mode 100644 preview/assets/back-to-top-8efcbe56.svg create mode 100644 preview/assets/docsearch-1d421ddb.js create mode 100644 preview/assets/filterQueryParam.html-428a3252.js create mode 100644 preview/assets/filterQueryParam.html-d51cd871.js create mode 100644 preview/assets/filterQueryParamCode.html-18ac53c8.js create mode 100644 preview/assets/filterQueryParamCode.html-293978be.js create mode 100644 preview/assets/filterQueryParamExample.html-95d3a481.js create mode 100644 preview/assets/filterQueryParamExample.html-c90c301f.js create mode 100644 preview/assets/index-5161ad19.js create mode 100644 preview/assets/index.html-06effaf7.js create mode 100644 preview/assets/index.html-09f857ca.js create mode 100644 preview/assets/index.html-15fde261.js create mode 100644 preview/assets/index.html-1ae75d86.js create mode 100644 preview/assets/index.html-1d68b11c.js create mode 100644 preview/assets/index.html-1d87c569.js create mode 100644 preview/assets/index.html-233fe616.js create mode 100644 preview/assets/index.html-27f35336.js create mode 100644 preview/assets/index.html-284021a2.js create mode 100644 preview/assets/index.html-2fb78bd6.js create mode 100644 preview/assets/index.html-341d16d7.js create mode 100644 preview/assets/index.html-34c15120.js create mode 100644 preview/assets/index.html-36d5ffa2.js create mode 100644 preview/assets/index.html-3a8f0d9c.js create mode 100644 preview/assets/index.html-3bc7b1c6.js create mode 100644 preview/assets/index.html-40029d88.js create mode 100644 preview/assets/index.html-42466a4a.js create mode 100644 preview/assets/index.html-43ad52c1.js create mode 100644 preview/assets/index.html-47b50680.js create mode 100644 preview/assets/index.html-4a529ad3.js create mode 100644 preview/assets/index.html-4edd1034.js create mode 100644 preview/assets/index.html-4f4b887a.js create mode 100644 preview/assets/index.html-549ff1f9.js create mode 100644 preview/assets/index.html-568e376b.js create mode 100644 preview/assets/index.html-585cce7b.js create mode 100644 preview/assets/index.html-59271dfc.js create mode 100644 preview/assets/index.html-5afa062f.js create mode 100644 preview/assets/index.html-5b46e0a4.js create mode 100644 preview/assets/index.html-602c2540.js create mode 100644 preview/assets/index.html-671e4912.js create mode 100644 preview/assets/index.html-692e45cb.js create mode 100644 preview/assets/index.html-6b858d80.js create mode 100644 preview/assets/index.html-6bb621c5.js create mode 100644 preview/assets/index.html-6ca7c7e1.js create mode 100644 preview/assets/index.html-6e427c03.js create mode 100644 preview/assets/index.html-6e94f439.js create mode 100644 preview/assets/index.html-70c61dd5.js create mode 100644 preview/assets/index.html-76d114a5.js create mode 100644 preview/assets/index.html-797fb107.js create mode 100644 preview/assets/index.html-7af3bcd3.js create mode 100644 preview/assets/index.html-7b20a00b.js create mode 100644 preview/assets/index.html-7d2ae716.js create mode 100644 preview/assets/index.html-7dc4890d.js create mode 100644 preview/assets/index.html-7f453789.js create mode 100644 preview/assets/index.html-854328d1.js create mode 100644 preview/assets/index.html-88fcd172.js create mode 100644 preview/assets/index.html-8a07e160.js create mode 100644 preview/assets/index.html-8ad94fae.js create mode 100644 preview/assets/index.html-8f681a20.js create mode 100644 preview/assets/index.html-9094b141.js create mode 100644 preview/assets/index.html-93ff6c80.js create mode 100644 preview/assets/index.html-94f1d832.js create mode 100644 preview/assets/index.html-963a4ecf.js create mode 100644 preview/assets/index.html-a08056c0.js create mode 100644 preview/assets/index.html-a0e9a4ad.js create mode 100644 preview/assets/index.html-a223bf40.js create mode 100644 preview/assets/index.html-a2d4ab5c.js create mode 100644 preview/assets/index.html-a48fd168.js create mode 100644 preview/assets/index.html-a70b6cf1.js create mode 100644 preview/assets/index.html-a7337040.js create mode 100644 preview/assets/index.html-a9eb55a1.js create mode 100644 preview/assets/index.html-b4c829a8.js create mode 100644 preview/assets/index.html-bc520f9b.js create mode 100644 preview/assets/index.html-bca8604a.js create mode 100644 preview/assets/index.html-c46be575.js create mode 100644 preview/assets/index.html-c67caa0a.js create mode 100644 preview/assets/index.html-caa38aac.js create mode 100644 preview/assets/index.html-ccd9b97c.js create mode 100644 preview/assets/index.html-ced62edd.js create mode 100644 preview/assets/index.html-d1326e32.js create mode 100644 preview/assets/index.html-ddb984f8.js create mode 100644 preview/assets/index.html-e1863c04.js create mode 100644 preview/assets/index.html-e44840e5.js create mode 100644 preview/assets/index.html-e67b9cd4.js create mode 100644 preview/assets/index.html-f8b7a659.js create mode 100644 preview/assets/index.html-f9768eb1.js create mode 100644 preview/assets/index.html-fae03536.js create mode 100644 preview/assets/index.html-fd34968b.js create mode 100644 preview/assets/jmespathFilter.html-35e45519.js create mode 100644 preview/assets/jmespathFilter.html-7c48b0c1.js create mode 100644 preview/assets/style-8fbc3cfb.css create mode 100644 preview/assets/style-e9220a04.js create mode 100644 preview/assets/throttle.html-32dcc936.js create mode 100644 preview/assets/throttle.html-4a67c62e.js create mode 100644 preview/assets/whereQueryParam.html-10f3d62d.js create mode 100644 preview/assets/whereQueryParam.html-889e420d.js create mode 100644 preview/assets/whereQueryParamCode.html-33176c09.js create mode 100644 preview/assets/whereQueryParamCode.html-84587f03.js create mode 100644 preview/assets/whereQueryParamExample.html-10f69adc.js create mode 100644 preview/assets/whereQueryParamExample.html-68248e1c.js create mode 100644 preview/attachments/benchmark-email.txt create mode 100644 preview/docs/acknowledgments/index.html create mode 100644 preview/docs/api-administrator/index.html create mode 100644 preview/docs/api-bounce/index.html create mode 100644 preview/docs/api-config/index.html create mode 100644 preview/docs/api-notification/index.html create mode 100644 preview/docs/api-overview/index.html create mode 100644 preview/docs/api-subscription/index.html create mode 100644 preview/docs/benchmarks/index.html create mode 100644 preview/docs/bulk-import/index.html create mode 100644 preview/docs/conduct/index.html create mode 100644 preview/docs/config-adminIpList/index.html create mode 100644 preview/docs/config-certificates/index.html create mode 100644 preview/docs/config-cronJobs/index.html create mode 100644 preview/docs/config-database/index.html create mode 100644 preview/docs/config-email/index.html create mode 100644 preview/docs/config-httpHost/index.html create mode 100644 preview/docs/config-internalHttpHost/index.html create mode 100644 preview/docs/config-middleware/index.html create mode 100644 preview/docs/config-nodeRoles/index.html create mode 100644 preview/docs/config-notification/index.html create mode 100644 preview/docs/config-oidc/index.html create mode 100644 preview/docs/config-overview/index.html create mode 100644 preview/docs/config-reverseProxyIpLists/index.html create mode 100644 preview/docs/config-rsaKeys/index.html create mode 100644 preview/docs/config-sms/index.html create mode 100644 preview/docs/config-subscription/index.html create mode 100644 preview/docs/config-workerProcessCount/index.html create mode 100644 preview/docs/developer-notes/index.html create mode 100644 preview/docs/health-check/index.html create mode 100644 preview/docs/index.html create mode 100644 preview/docs/installation/index.html create mode 100644 preview/docs/memory-dump/index.html create mode 100644 preview/docs/overview/index.html create mode 100644 preview/docs/quickstart/index.html create mode 100644 preview/docs/shared/filterQueryParam.html create mode 100644 preview/docs/shared/filterQueryParamCode.html create mode 100644 preview/docs/shared/filterQueryParamExample.html create mode 100644 preview/docs/shared/jmespathFilter.html create mode 100644 preview/docs/shared/throttle.html create mode 100644 preview/docs/shared/whereQueryParam.html create mode 100644 preview/docs/shared/whereQueryParamCode.html create mode 100644 preview/docs/shared/whereQueryParamExample.html create mode 100644 preview/docs/upgrade/index.html create mode 100644 preview/docs/web-console/index.html create mode 100644 preview/docs/what's-new/index.html create mode 100644 preview/drawings/subscription sequence.vsdx create mode 100644 preview/favicon.ico create mode 100644 preview/google5b66eb7c005753e3.html create mode 100644 preview/help/index.html create mode 100644 preview/img/admin-data-models.svg create mode 100644 preview/img/architecture.svg create mode 100644 preview/img/list-unsubscription.png create mode 100644 preview/img/logo.svg create mode 100644 preview/img/subscription-multi-service-provider.png create mode 100644 preview/img/subscription-single-service-provider.png create mode 100644 preview/index.html diff --git a/preview/404.html b/preview/404.html new file mode 100644 index 000000000..3e75829be --- /dev/null +++ b/preview/404.html @@ -0,0 +1,33 @@ + + + + + + + + + NotifyBC + + + + +

404

Looks like we've got some broken links.
Take me home
+ + + diff --git a/preview/assets/404.html-6024058c.js b/preview/assets/404.html-6024058c.js new file mode 100644 index 000000000..64a3f5fed --- /dev/null +++ b/preview/assets/404.html-6024058c.js @@ -0,0 +1 @@ +import{_ as e,o as c,c as t}from"./app-73097456.js";const _={};function o(r,n){return c(),t("div")}const a=e(_,[["render",o],["__file","404.html.vue"]]);export{a as default}; diff --git a/preview/assets/404.html-60b35caa.js b/preview/assets/404.html-60b35caa.js new file mode 100644 index 000000000..7a25b17a4 --- /dev/null +++ b/preview/assets/404.html-60b35caa.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-3706649a","path":"/404.html","title":"","lang":"en-US","frontmatter":{"layout":"NotFound"},"headers":[],"git":{},"filePathRelative":null}');export{t as data}; diff --git a/preview/assets/app-73097456.js b/preview/assets/app-73097456.js new file mode 100644 index 000000000..b3115147b --- /dev/null +++ b/preview/assets/app-73097456.js @@ -0,0 +1,10 @@ +const ma="modulepreload",va=function(e){return"/NotifyBC/preview/"+e},os={},O=function(t,n,o){if(!n||n.length===0)return t();const r=document.getElementsByTagName("link");return Promise.all(n.map(s=>{if(s=va(s),s in os)return;os[s]=!0;const i=s.endsWith(".css"),l=i?'[rel="stylesheet"]':"";if(!!o)for(let u=r.length-1;u>=0;u--){const f=r[u];if(f.href===s&&(!i||f.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${s}"]${l}`))return;const c=document.createElement("link");if(c.rel=i?"stylesheet":ma,i||(c.as="script",c.crossOrigin=""),c.href=s,document.head.appendChild(c),i)return new Promise((u,f)=>{c.addEventListener("load",u),c.addEventListener("error",()=>f(new Error(`Unable to preload CSS for ${s}`)))})})).then(()=>t()).catch(s=>{const i=new Event("vite:preloadError",{cancelable:!0});if(i.payload=s,window.dispatchEvent(i),!i.defaultPrevented)throw s})};function yr(e,t){const n=Object.create(null),o=e.split(",");for(let r=0;r!!n[r.toLowerCase()]:r=>!!n[r]}const Te={},rn=[],st=()=>{},ga=()=>!1,_a=/^on[^a-z]/,zn=e=>_a.test(e),Er=e=>e.startsWith("onUpdate:"),Se=Object.assign,wr=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},ba=Object.prototype.hasOwnProperty,pe=(e,t)=>ba.call(e,t),q=Array.isArray,sn=e=>Un(e)==="[object Map]",So=e=>Un(e)==="[object Set]",rs=e=>Un(e)==="[object Date]",re=e=>typeof e=="function",me=e=>typeof e=="string",Rn=e=>typeof e=="symbol",ye=e=>e!==null&&typeof e=="object",xi=e=>ye(e)&&re(e.then)&&re(e.catch),Pi=Object.prototype.toString,Un=e=>Pi.call(e),ya=e=>Un(e).slice(8,-1),Oi=e=>Un(e)==="[object Object]",Cr=e=>me(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,xn=yr(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ko=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Ea=/-(\w)/g,ft=ko(e=>e.replace(Ea,(t,n)=>n?n.toUpperCase():"")),wa=/\B([A-Z])/g,Yt=ko(e=>e.replace(wa,"-$1").toLowerCase()),Io=ko(e=>e.charAt(0).toUpperCase()+e.slice(1)),Uo=ko(e=>e?`on${Io(e)}`:""),Dn=(e,t)=>!Object.is(e,t),fo=(e,t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,value:n})},Si=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Ca=e=>{const t=me(e)?Number(e):NaN;return isNaN(t)?e:t};let ss;const or=()=>ss||(ss=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Qt(e){if(q(e)){const t={};for(let n=0;n{if(n){const o=n.split(La);o.length>1&&(t[o[0].trim()]=o[1].trim())}}),t}function ze(e){let t="";if(me(e))t=e;else if(q(e))for(let n=0;nAo(n,t))}const Ie=e=>me(e)?e:e==null?"":q(e)||ye(e)&&(e.toString===Pi||!re(e.toString))?JSON.stringify(e,Ii,2):String(e),Ii=(e,t)=>t&&t.__v_isRef?Ii(e,t.value):sn(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[o,r])=>(n[`${o} =>`]=r,n),{})}:So(t)?{[`Set(${t.size})`]:[...t.values()]}:ye(t)&&!q(t)&&!Oi(t)?String(t):t;let Qe;class Aa{constructor(t=!1){this.detached=t,this._active=!0,this.effects=[],this.cleanups=[],this.parent=Qe,!t&&Qe&&(this.index=(Qe.scopes||(Qe.scopes=[])).push(this)-1)}get active(){return this._active}run(t){if(this._active){const n=Qe;try{return Qe=this,t()}finally{Qe=n}}}on(){Qe=this}off(){Qe=this.parent}stop(t){if(this._active){let n,o;for(n=0,o=this.effects.length;n{const t=new Set(e);return t.w=0,t.n=0,t},Ri=e=>(e.w&Mt)>0,Di=e=>(e.n&Mt)>0,$a=({deps:e})=>{if(e.length)for(let t=0;t{const{deps:t}=e;if(t.length){let n=0;for(let o=0;o{(u==="length"||u>=a)&&l.push(c)})}else switch(n!==void 0&&l.push(i.get(n)),t){case"add":q(e)?Cr(n)&&l.push(i.get("length")):(l.push(i.get(qt)),sn(e)&&l.push(i.get(sr)));break;case"delete":q(e)||(l.push(i.get(qt)),sn(e)&&l.push(i.get(sr)));break;case"set":sn(e)&&l.push(i.get(qt));break}if(l.length===1)l[0]&&ir(l[0]);else{const a=[];for(const c of l)c&&a.push(...c);ir(Tr(a))}}function ir(e,t){const n=q(e)?e:[...e];for(const o of n)o.computed&&ls(o);for(const o of n)o.computed||ls(o)}function ls(e,t){(e!==ot||e.allowRecurse)&&(e.scheduler?e.scheduler():e.run())}function Na(e,t){var n;return(n=vo.get(e))==null?void 0:n.get(t)}const Ha=yr("__proto__,__v_isRef,__isVue"),Ni=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Rn)),Ba=xr(),ja=xr(!1,!0),Va=xr(!0),as=Fa();function Fa(){const e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...n){const o=he(this);for(let s=0,i=this.length;s{e[t]=function(...n){vn();const o=he(this)[t].apply(this,n);return gn(),o}}),e}function za(e){const t=he(this);return qe(t,"has",e),t.hasOwnProperty(e)}function xr(e=!1,t=!1){return function(o,r,s){if(r==="__v_isReactive")return!e;if(r==="__v_isReadonly")return e;if(r==="__v_isShallow")return t;if(r==="__v_raw"&&s===(e?t?sc:Fi:t?Vi:ji).get(o))return o;const i=q(o);if(!e){if(i&&pe(as,r))return Reflect.get(as,r,s);if(r==="hasOwnProperty")return za}const l=Reflect.get(o,r,s);return(Rn(r)?Ni.has(r):Ha(r))||(e||qe(o,"get",r),t)?l:Ae(l)?i&&Cr(r)?l:l.value:ye(l)?e?_n(l):Kn(l):l}}const Ua=Hi(),Ka=Hi(!0);function Hi(e=!1){return function(n,o,r,s){let i=n[o];if(un(i)&&Ae(i)&&!Ae(r))return!1;if(!e&&(!go(r)&&!un(r)&&(i=he(i),r=he(r)),!q(n)&&Ae(i)&&!Ae(r)))return i.value=r,!0;const l=q(n)&&Cr(o)?Number(o)e,Ro=e=>Reflect.getPrototypeOf(e);function Zn(e,t,n=!1,o=!1){e=e.__v_raw;const r=he(e),s=he(t);n||(t!==s&&qe(r,"get",t),qe(r,"get",s));const{has:i}=Ro(r),l=o?Pr:n?kr:$n;if(i.call(r,t))return l(e.get(t));if(i.call(r,s))return l(e.get(s));e!==r&&e.get(t)}function Xn(e,t=!1){const n=this.__v_raw,o=he(n),r=he(e);return t||(e!==r&&qe(o,"has",e),qe(o,"has",r)),e===r?n.has(e):n.has(e)||n.has(r)}function eo(e,t=!1){return e=e.__v_raw,!t&&qe(he(e),"iterate",qt),Reflect.get(e,"size",e)}function cs(e){e=he(e);const t=he(this);return Ro(t).has.call(t,e)||(t.add(e),_t(t,"add",e,e)),this}function us(e,t){t=he(t);const n=he(this),{has:o,get:r}=Ro(n);let s=o.call(n,e);s||(e=he(e),s=o.call(n,e));const i=r.call(n,e);return n.set(e,t),s?Dn(t,i)&&_t(n,"set",e,t):_t(n,"add",e,t),this}function fs(e){const t=he(this),{has:n,get:o}=Ro(t);let r=n.call(t,e);r||(e=he(e),r=n.call(t,e)),o&&o.call(t,e);const s=t.delete(e);return r&&_t(t,"delete",e,void 0),s}function ds(){const e=he(this),t=e.size!==0,n=e.clear();return t&&_t(e,"clear",void 0,void 0),n}function to(e,t){return function(o,r){const s=this,i=s.__v_raw,l=he(i),a=t?Pr:e?kr:$n;return!e&&qe(l,"iterate",qt),i.forEach((c,u)=>o.call(r,a(c),a(u),s))}}function no(e,t,n){return function(...o){const r=this.__v_raw,s=he(r),i=sn(s),l=e==="entries"||e===Symbol.iterator&&i,a=e==="keys"&&i,c=r[e](...o),u=n?Pr:t?kr:$n;return!t&&qe(s,"iterate",a?sr:qt),{next(){const{value:f,done:p}=c.next();return p?{value:f,done:p}:{value:l?[u(f[0]),u(f[1])]:u(f),done:p}},[Symbol.iterator](){return this}}}}function xt(e){return function(...t){return e==="delete"?!1:this}}function Ga(){const e={get(s){return Zn(this,s)},get size(){return eo(this)},has:Xn,add:cs,set:us,delete:fs,clear:ds,forEach:to(!1,!1)},t={get(s){return Zn(this,s,!1,!0)},get size(){return eo(this)},has:Xn,add:cs,set:us,delete:fs,clear:ds,forEach:to(!1,!0)},n={get(s){return Zn(this,s,!0)},get size(){return eo(this,!0)},has(s){return Xn.call(this,s,!0)},add:xt("add"),set:xt("set"),delete:xt("delete"),clear:xt("clear"),forEach:to(!0,!1)},o={get(s){return Zn(this,s,!0,!0)},get size(){return eo(this,!0)},has(s){return Xn.call(this,s,!0)},add:xt("add"),set:xt("set"),delete:xt("delete"),clear:xt("clear"),forEach:to(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(s=>{e[s]=no(s,!1,!1),n[s]=no(s,!0,!1),t[s]=no(s,!1,!0),o[s]=no(s,!0,!0)}),[e,n,t,o]}const[Za,Xa,ec,tc]=Ga();function Or(e,t){const n=t?e?tc:ec:e?Xa:Za;return(o,r,s)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?o:Reflect.get(pe(n,r)&&r in o?n:o,r,s)}const nc={get:Or(!1,!1)},oc={get:Or(!1,!0)},rc={get:Or(!0,!1)},ji=new WeakMap,Vi=new WeakMap,Fi=new WeakMap,sc=new WeakMap;function ic(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function lc(e){return e.__v_skip||!Object.isExtensible(e)?0:ic(ya(e))}function Kn(e){return un(e)?e:Sr(e,!1,Bi,nc,ji)}function zi(e){return Sr(e,!1,Ya,oc,Vi)}function _n(e){return Sr(e,!0,Qa,rc,Fi)}function Sr(e,t,n,o,r){if(!ye(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const s=r.get(e);if(s)return s;const i=lc(e);if(i===0)return e;const l=new Proxy(e,i===2?o:n);return r.set(e,l),l}function ln(e){return un(e)?ln(e.__v_raw):!!(e&&e.__v_isReactive)}function un(e){return!!(e&&e.__v_isReadonly)}function go(e){return!!(e&&e.__v_isShallow)}function Ui(e){return ln(e)||un(e)}function he(e){const t=e&&e.__v_raw;return t?he(t):e}function Ki(e){return mo(e,"__v_skip",!0),e}const $n=e=>ye(e)?Kn(e):e,kr=e=>ye(e)?_n(e):e;function Ir(e){Rt&&ot&&(e=he(e),Mi(e.dep||(e.dep=Tr())))}function Ar(e,t){e=he(e);const n=e.dep;n&&ir(n)}function Ae(e){return!!(e&&e.__v_isRef===!0)}function be(e){return qi(e,!1)}function Rr(e){return qi(e,!0)}function qi(e,t){return Ae(e)?e:new ac(e,t)}class ac{constructor(t,n){this.__v_isShallow=n,this.dep=void 0,this.__v_isRef=!0,this._rawValue=n?t:he(t),this._value=n?t:$n(t)}get value(){return Ir(this),this._value}set value(t){const n=this.__v_isShallow||go(t)||un(t);t=n?t:he(t),Dn(t,this._rawValue)&&(this._rawValue=t,this._value=n?t:$n(t),Ar(this))}}function X(e){return Ae(e)?e.value:e}const cc={get:(e,t,n)=>X(Reflect.get(e,t,n)),set:(e,t,n,o)=>{const r=e[t];return Ae(r)&&!Ae(n)?(r.value=n,!0):Reflect.set(e,t,n,o)}};function Wi(e){return ln(e)?e:new Proxy(e,cc)}class uc{constructor(t){this.dep=void 0,this.__v_isRef=!0;const{get:n,set:o}=t(()=>Ir(this),()=>Ar(this));this._get=n,this._set=o}get value(){return this._get()}set value(t){this._set(t)}}function fc(e){return new uc(e)}function Dr(e){const t=q(e)?new Array(e.length):{};for(const n in e)t[n]=Ji(e,n);return t}class dc{constructor(t,n,o){this._object=t,this._key=n,this._defaultValue=o,this.__v_isRef=!0}get value(){const t=this._object[this._key];return t===void 0?this._defaultValue:t}set value(t){this._object[this._key]=t}get dep(){return Na(he(this._object),this._key)}}class pc{constructor(t){this._getter=t,this.__v_isRef=!0,this.__v_isReadonly=!0}get value(){return this._getter()}}function hc(e,t,n){return Ae(e)?e:re(e)?new pc(e):ye(e)&&arguments.length>1?Ji(e,t,n):be(e)}function Ji(e,t,n){const o=e[t];return Ae(o)?o:new dc(e,t,n)}class mc{constructor(t,n,o,r){this._setter=n,this.dep=void 0,this.__v_isRef=!0,this.__v_isReadonly=!1,this._dirty=!0,this.effect=new Lr(t,()=>{this._dirty||(this._dirty=!0,Ar(this))}),this.effect.computed=this,this.effect.active=this._cacheable=!r,this.__v_isReadonly=o}get value(){const t=he(this);return Ir(t),(t._dirty||!t._cacheable)&&(t._dirty=!1,t._value=t.effect.run()),t._value}set value(t){this._setter(t)}}function vc(e,t,n=!1){let o,r;const s=re(e);return s?(o=e,r=st):(o=e.get,r=e.set),new mc(o,r,s||!r,n)}function Dt(e,t,n,o){let r;try{r=o?e(...o):e()}catch(s){qn(s,t,n)}return r}function Xe(e,t,n,o){if(re(e)){const s=Dt(e,t,n,o);return s&&xi(s)&&s.catch(i=>{qn(i,t,n)}),s}const r=[];for(let s=0;s>>1;Nn(je[o])ut&&je.splice(t,1)}function yc(e){q(e)?an.push(...e):(!mt||!mt.includes(e,e.allowRecurse?Ft+1:Ft))&&an.push(e),Yi()}function ps(e,t=Mn?ut+1:0){for(;tNn(n)-Nn(o)),Ft=0;Fte.id==null?1/0:e.id,Ec=(e,t)=>{const n=Nn(e)-Nn(t);if(n===0){if(e.pre&&!t.pre)return-1;if(t.pre&&!e.pre)return 1}return n};function Gi(e){lr=!1,Mn=!0,je.sort(Ec);const t=st;try{for(ut=0;utme(v)?v.trim():v)),f&&(r=n.map(Si))}let l,a=o[l=Uo(t)]||o[l=Uo(ft(t))];!a&&s&&(a=o[l=Uo(Yt(t))]),a&&Xe(a,e,6,r);const c=o[l+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Xe(c,e,6,r)}}function Zi(e,t,n=!1){const o=t.emitsCache,r=o.get(e);if(r!==void 0)return r;const s=e.emits;let i={},l=!1;if(!re(e)){const a=c=>{const u=Zi(c,t,!0);u&&(l=!0,Se(i,u))};!n&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}return!s&&!l?(ye(e)&&o.set(e,null),null):(q(s)?s.forEach(a=>i[a]=null):Se(i,s),ye(e)&&o.set(e,i),i)}function Mo(e,t){return!e||!zn(t)?!1:(t=t.slice(2).replace(/Once$/,""),pe(e,t[0].toLowerCase()+t.slice(1))||pe(e,Yt(t))||pe(e,t))}let Ne=null,No=null;function bo(e){const t=Ne;return Ne=e,No=e&&e.type.__scopeId||null,t}function Cc(e){No=e}function Tc(){No=null}function Me(e,t=Ne,n){if(!t||e._n)return e;const o=(...r)=>{o._d&&Ls(-1);const s=bo(t);let i;try{i=e(...r)}finally{bo(s),o._d&&Ls(1)}return i};return o._n=!0,o._c=!0,o._d=!0,o}function Ko(e){const{type:t,vnode:n,proxy:o,withProxy:r,props:s,propsOptions:[i],slots:l,attrs:a,emit:c,render:u,renderCache:f,data:p,setupState:v,ctx:y,inheritAttrs:w}=e;let x,g;const b=bo(e);try{if(n.shapeFlag&4){const k=r||o;x=nt(u.call(k,k,f,s,v,p,y)),g=a}else{const k=t;x=nt(k.length>1?k(s,{attrs:a,slots:l,emit:c}):k(s,null)),g=t.props?a:Lc(a)}}catch(k){Sn.length=0,qn(k,e,1),x=ne(Ye)}let I=x;if(g&&w!==!1){const k=Object.keys(g),{shapeFlag:W}=I;k.length&&W&7&&(i&&k.some(Er)&&(g=xc(g,i)),I=Nt(I,g))}return n.dirs&&(I=Nt(I),I.dirs=I.dirs?I.dirs.concat(n.dirs):n.dirs),n.transition&&(I.transition=n.transition),x=I,bo(b),x}const Lc=e=>{let t;for(const n in e)(n==="class"||n==="style"||zn(n))&&((t||(t={}))[n]=e[n]);return t},xc=(e,t)=>{const n={};for(const o in e)(!Er(o)||!(o.slice(9)in t))&&(n[o]=e[o]);return n};function Pc(e,t,n){const{props:o,children:r,component:s}=e,{props:i,children:l,patchFlag:a}=t,c=s.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&a>=0){if(a&1024)return!0;if(a&16)return o?hs(o,i,c):!!i;if(a&8){const u=t.dynamicProps;for(let f=0;fe.__isSuspense;function Xi(e,t){t&&t.pendingBranch?q(e)?t.effects.push(...e):t.effects.push(e):yc(e)}function el(e,t){return Mr(e,null,t)}const oo={};function et(e,t,n){return Mr(e,t,n)}function Mr(e,t,{immediate:n,deep:o,flush:r,onTrack:s,onTrigger:i}=Te){var l;const a=Ai()===((l=ke)==null?void 0:l.scope)?ke:null;let c,u=!1,f=!1;if(Ae(e)?(c=()=>e.value,u=go(e)):ln(e)?(c=()=>e,o=!0):q(e)?(f=!0,u=e.some(k=>ln(k)||go(k)),c=()=>e.map(k=>{if(Ae(k))return k.value;if(ln(k))return Kt(k);if(re(k))return Dt(k,a,2)})):re(e)?t?c=()=>Dt(e,a,2):c=()=>{if(!(a&&a.isUnmounted))return p&&p(),Xe(e,a,3,[v])}:c=st,t&&o){const k=c;c=()=>Kt(k())}let p,v=k=>{p=b.onStop=()=>{Dt(k,a,4)}},y;if(pn)if(v=st,t?n&&Xe(t,a,3,[c(),f?[]:void 0,v]):c(),r==="sync"){const k=Cu();y=k.__watcherHandles||(k.__watcherHandles=[])}else return st;let w=f?new Array(e.length).fill(oo):oo;const x=()=>{if(b.active)if(t){const k=b.run();(o||u||(f?k.some((W,ee)=>Dn(W,w[ee])):Dn(k,w)))&&(p&&p(),Xe(t,a,3,[k,w===oo?void 0:f&&w[0]===oo?[]:w,v]),w=k)}else b.run()};x.allowRecurse=!!t;let g;r==="sync"?g=x:r==="post"?g=()=>Ke(x,a&&a.suspense):(x.pre=!0,a&&(x.id=a.uid),g=()=>$o(x));const b=new Lr(c,g);t?n?x():w=b.run():r==="post"?Ke(b.run.bind(b),a&&a.suspense):b.run();const I=()=>{b.stop(),a&&a.scope&&wr(a.scope.effects,b)};return y&&y.push(I),I}function kc(e,t,n){const o=this.proxy,r=me(e)?e.includes(".")?tl(o,e):()=>o[e]:e.bind(o,o);let s;re(t)?s=t:(s=t.handler,n=t);const i=ke;dn(this);const l=Mr(r,s.bind(o),n);return i?dn(i):Jt(),l}function tl(e,t){const n=t.split(".");return()=>{let o=e;for(let r=0;r{Kt(n,t)});else if(Oi(e))for(const n in e)Kt(e[n],t);return e}function Hn(e,t){const n=Ne;if(n===null)return e;const o=Vo(n)||n.proxy,r=e.dirs||(e.dirs=[]);for(let s=0;s{e.isMounted=!0}),Jn(()=>{e.isUnmounting=!0}),e}const Ge=[Function,Array],nl={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Ge,onEnter:Ge,onAfterEnter:Ge,onEnterCancelled:Ge,onBeforeLeave:Ge,onLeave:Ge,onAfterLeave:Ge,onLeaveCancelled:Ge,onBeforeAppear:Ge,onAppear:Ge,onAfterAppear:Ge,onAppearCancelled:Ge},Ac={name:"BaseTransition",props:nl,setup(e,{slots:t}){const n=yl(),o=Ic();let r;return()=>{const s=t.default&&rl(t.default(),!0);if(!s||!s.length)return;let i=s[0];if(s.length>1){for(const w of s)if(w.type!==Ye){i=w;break}}const l=he(e),{mode:a}=l;if(o.isLeaving)return qo(i);const c=ms(i);if(!c)return qo(i);const u=ar(c,l,o,n);cr(c,u);const f=n.subTree,p=f&&ms(f);let v=!1;const{getTransitionKey:y}=c.type;if(y){const w=y();r===void 0?r=w:w!==r&&(r=w,v=!0)}if(p&&p.type!==Ye&&(!zt(c,p)||v)){const w=ar(p,l,o,n);if(cr(p,w),a==="out-in")return o.isLeaving=!0,w.afterLeave=()=>{o.isLeaving=!1,n.update.active!==!1&&n.update()},qo(i);a==="in-out"&&c.type!==Ye&&(w.delayLeave=(x,g,b)=>{const I=ol(o,p);I[String(p.key)]=p,x._leaveCb=()=>{g(),x._leaveCb=void 0,delete u.delayedLeave},u.delayedLeave=b})}return i}}},Rc=Ac;function ol(e,t){const{leavingVNodes:n}=e;let o=n.get(t.type);return o||(o=Object.create(null),n.set(t.type,o)),o}function ar(e,t,n,o){const{appear:r,mode:s,persisted:i=!1,onBeforeEnter:l,onEnter:a,onAfterEnter:c,onEnterCancelled:u,onBeforeLeave:f,onLeave:p,onAfterLeave:v,onLeaveCancelled:y,onBeforeAppear:w,onAppear:x,onAfterAppear:g,onAppearCancelled:b}=t,I=String(e.key),k=ol(n,e),W=(m,F)=>{m&&Xe(m,o,9,F)},ee=(m,F)=>{const B=F[1];W(m,F),q(m)?m.every(Y=>Y.length<=1)&&B():m.length<=1&&B()},N={mode:s,persisted:i,beforeEnter(m){let F=l;if(!n.isMounted)if(r)F=w||l;else return;m._leaveCb&&m._leaveCb(!0);const B=k[I];B&&zt(e,B)&&B.el._leaveCb&&B.el._leaveCb(),W(F,[m])},enter(m){let F=a,B=c,Y=u;if(!n.isMounted)if(r)F=x||a,B=g||c,Y=b||u;else return;let L=!1;const R=m._enterCb=D=>{L||(L=!0,D?W(Y,[m]):W(B,[m]),N.delayedLeave&&N.delayedLeave(),m._enterCb=void 0)};F?ee(F,[m,R]):R()},leave(m,F){const B=String(e.key);if(m._enterCb&&m._enterCb(!0),n.isUnmounting)return F();W(f,[m]);let Y=!1;const L=m._leaveCb=R=>{Y||(Y=!0,F(),R?W(y,[m]):W(v,[m]),m._leaveCb=void 0,k[B]===e&&delete k[B])};k[B]=e,p?ee(p,[m,L]):L()},clone(m){return ar(m,t,n,o)}};return N}function qo(e){if(Wn(e))return e=Nt(e),e.children=null,e}function ms(e){return Wn(e)?e.children?e.children[0]:void 0:e}function cr(e,t){e.shapeFlag&6&&e.component?cr(e.component.subTree,t):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function rl(e,t=!1,n){let o=[],r=0;for(let s=0;s1)for(let s=0;sSe({name:e.name},t,{setup:e}))():e}const cn=e=>!!e.type.__asyncLoader;function te(e){re(e)&&(e={loader:e});const{loader:t,loadingComponent:n,errorComponent:o,delay:r=200,timeout:s,suspensible:i=!0,onError:l}=e;let a=null,c,u=0;const f=()=>(u++,a=null,p()),p=()=>{let v;return a||(v=a=t().catch(y=>{if(y=y instanceof Error?y:new Error(String(y)),l)return new Promise((w,x)=>{l(y,()=>w(f()),()=>x(y),u+1)});throw y}).then(y=>v!==a&&a?a:(y&&(y.__esModule||y[Symbol.toStringTag]==="Module")&&(y=y.default),c=y,y)))};return fe({name:"AsyncComponentWrapper",__asyncLoader:p,get __asyncResolved(){return c},setup(){const v=ke;if(c)return()=>Wo(c,v);const y=b=>{a=null,qn(b,v,13,!o)};if(i&&v.suspense||pn)return p().then(b=>()=>Wo(b,v)).catch(b=>(y(b),()=>o?ne(o,{error:b}):null));const w=be(!1),x=be(),g=be(!!r);return r&&setTimeout(()=>{g.value=!1},r),s!=null&&setTimeout(()=>{if(!w.value&&!x.value){const b=new Error(`Async component timed out after ${s}ms.`);y(b),x.value=b}},s),p().then(()=>{w.value=!0,v.parent&&Wn(v.parent.vnode)&&$o(v.parent.update)}).catch(b=>{y(b),x.value=b}),()=>{if(w.value&&c)return Wo(c,v);if(x.value&&o)return ne(o,{error:x.value});if(n&&!g.value)return ne(n)}}})}function Wo(e,t){const{ref:n,props:o,children:r,ce:s}=t.vnode,i=ne(e,o,r);return i.ref=n,i.ce=s,delete t.vnode.ce,i}const Wn=e=>e.type.__isKeepAlive;function Dc(e,t){sl(e,"a",t)}function $c(e,t){sl(e,"da",t)}function sl(e,t,n=ke){const o=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(Ho(t,o,n),n){let r=n.parent;for(;r&&r.parent;)Wn(r.parent.vnode)&&Mc(o,t,n,r),r=r.parent}}function Mc(e,t,n,o){const r=Ho(t,e,o,!0);Bo(()=>{wr(o[t],r)},n)}function Ho(e,t,n=ke,o=!1){if(n){const r=n[e]||(n[e]=[]),s=t.__weh||(t.__weh=(...i)=>{if(n.isUnmounted)return;vn(),dn(n);const l=Xe(t,n,e,i);return Jt(),gn(),l});return o?r.unshift(s):r.push(s),s}}const wt=e=>(t,n=ke)=>(!pn||e==="sp")&&Ho(e,(...o)=>t(...o),n),Nc=wt("bm"),We=wt("m"),Hc=wt("bu"),il=wt("u"),Jn=wt("bum"),Bo=wt("um"),Bc=wt("sp"),jc=wt("rtg"),Vc=wt("rtc");function Fc(e,t=ke){Ho("ec",e,t)}const ll="components";function bt(e,t){return Uc(ll,e,!0,t)||e}const zc=Symbol.for("v-ndc");function Uc(e,t,n=!0,o=!1){const r=Ne||ke;if(r){const s=r.type;if(e===ll){const l=yu(s,!1);if(l&&(l===t||l===ft(t)||l===Io(ft(t))))return s}const i=vs(r[e]||s[e],t)||vs(r.appContext[e],t);return!i&&o?s:i}}function vs(e,t){return e&&(e[t]||e[ft(t)]||e[Io(ft(t))])}function yt(e,t,n,o){let r;const s=n&&n[o];if(q(e)||me(e)){r=new Array(e.length);for(let i=0,l=e.length;it(i,l,void 0,s&&s[l]));else{const i=Object.keys(e);r=new Array(i.length);for(let l=0,a=i.length;lCo(t)?!(t.type===Ye||t.type===Ee&&!al(t.children)):!0)?e:null}const ur=e=>e?El(e)?Vo(e)||e.proxy:ur(e.parent):null,Pn=Se(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>ur(e.parent),$root:e=>ur(e.root),$emit:e=>e.emit,$options:e=>Nr(e),$forceUpdate:e=>e.f||(e.f=()=>$o(e.update)),$nextTick:e=>e.n||(e.n=Do.bind(e.proxy)),$watch:e=>kc.bind(e)}),Jo=(e,t)=>e!==Te&&!e.__isScriptSetup&&pe(e,t),Kc={get({_:e},t){const{ctx:n,setupState:o,data:r,props:s,accessCache:i,type:l,appContext:a}=e;let c;if(t[0]!=="$"){const v=i[t];if(v!==void 0)switch(v){case 1:return o[t];case 2:return r[t];case 4:return n[t];case 3:return s[t]}else{if(Jo(o,t))return i[t]=1,o[t];if(r!==Te&&pe(r,t))return i[t]=2,r[t];if((c=e.propsOptions[0])&&pe(c,t))return i[t]=3,s[t];if(n!==Te&&pe(n,t))return i[t]=4,n[t];fr&&(i[t]=0)}}const u=Pn[t];let f,p;if(u)return t==="$attrs"&&qe(e,"get",t),u(e);if((f=l.__cssModules)&&(f=f[t]))return f;if(n!==Te&&pe(n,t))return i[t]=4,n[t];if(p=a.config.globalProperties,pe(p,t))return p[t]},set({_:e},t,n){const{data:o,setupState:r,ctx:s}=e;return Jo(r,t)?(r[t]=n,!0):o!==Te&&pe(o,t)?(o[t]=n,!0):pe(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(s[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:o,appContext:r,propsOptions:s}},i){let l;return!!n[i]||e!==Te&&pe(e,i)||Jo(t,i)||(l=s[0])&&pe(l,i)||pe(o,i)||pe(Pn,i)||pe(r.config.globalProperties,i)},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:pe(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function gs(e){return q(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let fr=!0;function qc(e){const t=Nr(e),n=e.proxy,o=e.ctx;fr=!1,t.beforeCreate&&_s(t.beforeCreate,e,"bc");const{data:r,computed:s,methods:i,watch:l,provide:a,inject:c,created:u,beforeMount:f,mounted:p,beforeUpdate:v,updated:y,activated:w,deactivated:x,beforeDestroy:g,beforeUnmount:b,destroyed:I,unmounted:k,render:W,renderTracked:ee,renderTriggered:N,errorCaptured:m,serverPrefetch:F,expose:B,inheritAttrs:Y,components:L,directives:R,filters:D}=t;if(c&&Wc(c,o,null),i)for(const se in i){const ie=i[se];re(ie)&&(o[se]=ie.bind(n))}if(r){const se=r.call(n,n);ye(se)&&(e.data=Kn(se))}if(fr=!0,s)for(const se in s){const ie=s[se],He=re(ie)?ie.bind(n,n):re(ie.get)?ie.get.bind(n,n):st,$e=!re(ie)&&re(ie.set)?ie.set.bind(n):st,Ue=z({get:He,set:$e});Object.defineProperty(o,se,{enumerable:!0,configurable:!0,get:()=>Ue.value,set:Be=>Ue.value=Be})}if(l)for(const se in l)cl(l[se],o,n,se);if(a){const se=re(a)?a.call(n):a;Reflect.ownKeys(se).forEach(ie=>{Wt(ie,se[ie])})}u&&_s(u,e,"c");function U(se,ie){q(ie)?ie.forEach(He=>se(He.bind(n))):ie&&se(ie.bind(n))}if(U(Nc,f),U(We,p),U(Hc,v),U(il,y),U(Dc,w),U($c,x),U(Fc,m),U(Vc,ee),U(jc,N),U(Jn,b),U(Bo,k),U(Bc,F),q(B))if(B.length){const se=e.exposed||(e.exposed={});B.forEach(ie=>{Object.defineProperty(se,ie,{get:()=>n[ie],set:He=>n[ie]=He})})}else e.exposed||(e.exposed={});W&&e.render===st&&(e.render=W),Y!=null&&(e.inheritAttrs=Y),L&&(e.components=L),R&&(e.directives=R)}function Wc(e,t,n=st){q(e)&&(e=dr(e));for(const o in e){const r=e[o];let s;ye(r)?"default"in r?s=Oe(r.from||o,r.default,!0):s=Oe(r.from||o):s=Oe(r),Ae(s)?Object.defineProperty(t,o,{enumerable:!0,configurable:!0,get:()=>s.value,set:i=>s.value=i}):t[o]=s}}function _s(e,t,n){Xe(q(e)?e.map(o=>o.bind(t.proxy)):e.bind(t.proxy),t,n)}function cl(e,t,n,o){const r=o.includes(".")?tl(n,o):()=>n[o];if(me(e)){const s=t[e];re(s)&&et(r,s)}else if(re(e))et(r,e.bind(n));else if(ye(e))if(q(e))e.forEach(s=>cl(s,t,n,o));else{const s=re(e.handler)?e.handler.bind(n):t[e.handler];re(s)&&et(r,s,e)}}function Nr(e){const t=e.type,{mixins:n,extends:o}=t,{mixins:r,optionsCache:s,config:{optionMergeStrategies:i}}=e.appContext,l=s.get(t);let a;return l?a=l:!r.length&&!n&&!o?a=t:(a={},r.length&&r.forEach(c=>yo(a,c,i,!0)),yo(a,t,i)),ye(t)&&s.set(t,a),a}function yo(e,t,n,o=!1){const{mixins:r,extends:s}=t;s&&yo(e,s,n,!0),r&&r.forEach(i=>yo(e,i,n,!0));for(const i in t)if(!(o&&i==="expose")){const l=Jc[i]||n&&n[i];e[i]=l?l(e[i],t[i]):t[i]}return e}const Jc={data:bs,props:ys,emits:ys,methods:Ln,computed:Ln,beforeCreate:Ve,created:Ve,beforeMount:Ve,mounted:Ve,beforeUpdate:Ve,updated:Ve,beforeDestroy:Ve,beforeUnmount:Ve,destroyed:Ve,unmounted:Ve,activated:Ve,deactivated:Ve,errorCaptured:Ve,serverPrefetch:Ve,components:Ln,directives:Ln,watch:Yc,provide:bs,inject:Qc};function bs(e,t){return t?e?function(){return Se(re(e)?e.call(this,this):e,re(t)?t.call(this,this):t)}:t:e}function Qc(e,t){return Ln(dr(e),dr(t))}function dr(e){if(q(e)){const t={};for(let n=0;n1)return n&&re(t)?t.call(o&&o.proxy):t}}function Xc(e,t,n,o=!1){const r={},s={};mo(s,jo,1),e.propsDefaults=Object.create(null),fl(e,t,r,s);for(const i in e.propsOptions[0])i in r||(r[i]=void 0);n?e.props=o?r:zi(r):e.type.props?e.props=r:e.props=s,e.attrs=s}function eu(e,t,n,o){const{props:r,attrs:s,vnode:{patchFlag:i}}=e,l=he(r),[a]=e.propsOptions;let c=!1;if((o||i>0)&&!(i&16)){if(i&8){const u=e.vnode.dynamicProps;for(let f=0;f{a=!0;const[p,v]=dl(f,t,!0);Se(i,p),v&&l.push(...v)};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!s&&!a)return ye(e)&&o.set(e,rn),rn;if(q(s))for(let u=0;u-1,v[1]=w<0||y-1||pe(v,"default"))&&l.push(f)}}}const c=[i,l];return ye(e)&&o.set(e,c),c}function Es(e){return e[0]!=="$"}function ws(e){const t=e&&e.toString().match(/^\s*(function|class) (\w+)/);return t?t[2]:e===null?"null":""}function Cs(e,t){return ws(e)===ws(t)}function Ts(e,t){return q(t)?t.findIndex(n=>Cs(n,e)):re(t)&&Cs(t,e)?0:-1}const pl=e=>e[0]==="_"||e==="$stable",Hr=e=>q(e)?e.map(nt):[nt(e)],tu=(e,t,n)=>{if(t._n)return t;const o=Me((...r)=>Hr(t(...r)),n);return o._c=!1,o},hl=(e,t,n)=>{const o=e._ctx;for(const r in e){if(pl(r))continue;const s=e[r];if(re(s))t[r]=tu(r,s,o);else if(s!=null){const i=Hr(s);t[r]=()=>i}}},ml=(e,t)=>{const n=Hr(t);e.slots.default=()=>n},nu=(e,t)=>{if(e.vnode.shapeFlag&32){const n=t._;n?(e.slots=he(t),mo(t,"_",n)):hl(t,e.slots={})}else e.slots={},t&&ml(e,t);mo(e.slots,jo,1)},ou=(e,t,n)=>{const{vnode:o,slots:r}=e;let s=!0,i=Te;if(o.shapeFlag&32){const l=t._;l?n&&l===1?s=!1:(Se(r,t),!n&&l===1&&delete r._):(s=!t.$stable,hl(t,r)),i=t}else t&&(ml(e,t),i={default:1});if(s)for(const l in r)!pl(l)&&!(l in i)&&delete r[l]};function wo(e,t,n,o,r=!1){if(q(e)){e.forEach((p,v)=>wo(p,t&&(q(t)?t[v]:t),n,o,r));return}if(cn(o)&&!r)return;const s=o.shapeFlag&4?Vo(o.component)||o.component.proxy:o.el,i=r?null:s,{i:l,r:a}=e,c=t&&t.r,u=l.refs===Te?l.refs={}:l.refs,f=l.setupState;if(c!=null&&c!==a&&(me(c)?(u[c]=null,pe(f,c)&&(f[c]=null)):Ae(c)&&(c.value=null)),re(a))Dt(a,l,12,[i,u]);else{const p=me(a),v=Ae(a);if(p||v){const y=()=>{if(e.f){const w=p?pe(f,a)?f[a]:u[a]:a.value;r?q(w)&&wr(w,s):q(w)?w.includes(s)||w.push(s):p?(u[a]=[s],pe(f,a)&&(f[a]=u[a])):(a.value=[s],e.k&&(u[e.k]=a.value))}else p?(u[a]=i,pe(f,a)&&(f[a]=i)):v&&(a.value=i,e.k&&(u[e.k]=i))};i?(y.id=-1,Ke(y,n)):y()}}}let Pt=!1;const ro=e=>/svg/.test(e.namespaceURI)&&e.tagName!=="foreignObject",so=e=>e.nodeType===8;function ru(e){const{mt:t,p:n,o:{patchProp:o,createText:r,nextSibling:s,parentNode:i,remove:l,insert:a,createComment:c}}=e,u=(g,b)=>{if(!b.hasChildNodes()){n(null,g,b),_o(),b._vnode=g;return}Pt=!1,f(b.firstChild,g,null,null,null),_o(),b._vnode=g,Pt&&console.error("Hydration completed but contains mismatches.")},f=(g,b,I,k,W,ee=!1)=>{const N=so(g)&&g.data==="[",m=()=>w(g,b,I,k,W,N),{type:F,ref:B,shapeFlag:Y,patchFlag:L}=b;let R=g.nodeType;b.el=g,L===-2&&(ee=!1,b.dynamicChildren=null);let D=null;switch(F){case fn:R!==3?b.children===""?(a(b.el=r(""),i(g),g),D=g):D=m():(g.data!==b.children&&(Pt=!0,g.data=b.children),D=s(g));break;case Ye:R!==8||N?D=m():D=s(g);break;case On:if(N&&(g=s(g),R=g.nodeType),R===1||R===3){D=g;const le=!b.children.length;for(let U=0;U{ee=ee||!!b.dynamicChildren;const{type:N,props:m,patchFlag:F,shapeFlag:B,dirs:Y}=b,L=N==="input"&&Y||N==="option";if(L||F!==-1){if(Y&&ct(b,null,I,"created"),m)if(L||!ee||F&48)for(const D in m)(L&&D.endsWith("value")||zn(D)&&!xn(D))&&o(g,D,null,m[D],!1,void 0,I);else m.onClick&&o(g,"onClick",null,m.onClick,!1,void 0,I);let R;if((R=m&&m.onVnodeBeforeMount)&&Ze(R,I,b),Y&&ct(b,null,I,"beforeMount"),((R=m&&m.onVnodeMounted)||Y)&&Xi(()=>{R&&Ze(R,I,b),Y&&ct(b,null,I,"mounted")},k),B&16&&!(m&&(m.innerHTML||m.textContent))){let D=v(g.firstChild,b,g,I,k,W,ee);for(;D;){Pt=!0;const le=D;D=D.nextSibling,l(le)}}else B&8&&g.textContent!==b.children&&(Pt=!0,g.textContent=b.children)}return g.nextSibling},v=(g,b,I,k,W,ee,N)=>{N=N||!!b.dynamicChildren;const m=b.children,F=m.length;for(let B=0;B{const{slotScopeIds:N}=b;N&&(W=W?W.concat(N):N);const m=i(g),F=v(s(g),b,m,I,k,W,ee);return F&&so(F)&&F.data==="]"?s(b.anchor=F):(Pt=!0,a(b.anchor=c("]"),m,F),F)},w=(g,b,I,k,W,ee)=>{if(Pt=!0,b.el=null,ee){const F=x(g);for(;;){const B=s(g);if(B&&B!==F)l(B);else break}}const N=s(g),m=i(g);return l(g),n(null,b,m,N,I,k,ro(m),W),N},x=g=>{let b=0;for(;g;)if(g=s(g),g&&so(g)&&(g.data==="["&&b++,g.data==="]")){if(b===0)return s(g);b--}return g};return[u,f]}const Ke=Xi;function su(e){return vl(e)}function iu(e){return vl(e,ru)}function vl(e,t){const n=or();n.__VUE__=!0;const{insert:o,remove:r,patchProp:s,createElement:i,createText:l,createComment:a,setText:c,setElementText:u,parentNode:f,nextSibling:p,setScopeId:v=st,insertStaticContent:y}=e,w=(d,h,_,E=null,T=null,P=null,j=!1,A=null,M=!!h.dynamicChildren)=>{if(d===h)return;d&&!zt(d,h)&&(E=C(d),Be(d,T,P,!0),d=null),h.patchFlag===-2&&(M=!1,h.dynamicChildren=null);const{type:S,ref:G,shapeFlag:K}=h;switch(S){case fn:x(d,h,_,E);break;case Ye:g(d,h,_,E);break;case On:d==null&&b(h,_,E,j);break;case Ee:L(d,h,_,E,T,P,j,A,M);break;default:K&1?W(d,h,_,E,T,P,j,A,M):K&6?R(d,h,_,E,T,P,j,A,M):(K&64||K&128)&&S.process(d,h,_,E,T,P,j,A,M,$)}G!=null&&T&&wo(G,d&&d.ref,P,h||d,!h)},x=(d,h,_,E)=>{if(d==null)o(h.el=l(h.children),_,E);else{const T=h.el=d.el;h.children!==d.children&&c(T,h.children)}},g=(d,h,_,E)=>{d==null?o(h.el=a(h.children||""),_,E):h.el=d.el},b=(d,h,_,E)=>{[d.el,d.anchor]=y(d.children,h,_,E,d.el,d.anchor)},I=({el:d,anchor:h},_,E)=>{let T;for(;d&&d!==h;)T=p(d),o(d,_,E),d=T;o(h,_,E)},k=({el:d,anchor:h})=>{let _;for(;d&&d!==h;)_=p(d),r(d),d=_;r(h)},W=(d,h,_,E,T,P,j,A,M)=>{j=j||h.type==="svg",d==null?ee(h,_,E,T,P,j,A,M):F(d,h,T,P,j,A,M)},ee=(d,h,_,E,T,P,j,A)=>{let M,S;const{type:G,props:K,shapeFlag:Z,transition:oe,dirs:ce}=d;if(M=d.el=i(d.type,P,K&&K.is,K),Z&8?u(M,d.children):Z&16&&m(d.children,M,null,E,T,P&&G!=="foreignObject",j,A),ce&&ct(d,null,E,"created"),N(M,d,d.scopeId,j,E),K){for(const _e in K)_e!=="value"&&!xn(_e)&&s(M,_e,null,K[_e],P,d.children,E,T,De);"value"in K&&s(M,"value",null,K.value),(S=K.onVnodeBeforeMount)&&Ze(S,E,d)}ce&&ct(d,null,E,"beforeMount");const we=(!T||T&&!T.pendingBranch)&&oe&&!oe.persisted;we&&oe.beforeEnter(M),o(M,h,_),((S=K&&K.onVnodeMounted)||we||ce)&&Ke(()=>{S&&Ze(S,E,d),we&&oe.enter(M),ce&&ct(d,null,E,"mounted")},T)},N=(d,h,_,E,T)=>{if(_&&v(d,_),E)for(let P=0;P{for(let S=M;S{const A=h.el=d.el;let{patchFlag:M,dynamicChildren:S,dirs:G}=h;M|=d.patchFlag&16;const K=d.props||Te,Z=h.props||Te;let oe;_&&Ht(_,!1),(oe=Z.onVnodeBeforeUpdate)&&Ze(oe,_,h,d),G&&ct(h,d,_,"beforeUpdate"),_&&Ht(_,!0);const ce=T&&h.type!=="foreignObject";if(S?B(d.dynamicChildren,S,A,_,E,ce,P):j||ie(d,h,A,null,_,E,ce,P,!1),M>0){if(M&16)Y(A,h,K,Z,_,E,T);else if(M&2&&K.class!==Z.class&&s(A,"class",null,Z.class,T),M&4&&s(A,"style",K.style,Z.style,T),M&8){const we=h.dynamicProps;for(let _e=0;_e{oe&&Ze(oe,_,h,d),G&&ct(h,d,_,"updated")},E)},B=(d,h,_,E,T,P,j)=>{for(let A=0;A{if(_!==E){if(_!==Te)for(const A in _)!xn(A)&&!(A in E)&&s(d,A,_[A],null,j,h.children,T,P,De);for(const A in E){if(xn(A))continue;const M=E[A],S=_[A];M!==S&&A!=="value"&&s(d,A,S,M,j,h.children,T,P,De)}"value"in E&&s(d,"value",_.value,E.value)}},L=(d,h,_,E,T,P,j,A,M)=>{const S=h.el=d?d.el:l(""),G=h.anchor=d?d.anchor:l("");let{patchFlag:K,dynamicChildren:Z,slotScopeIds:oe}=h;oe&&(A=A?A.concat(oe):oe),d==null?(o(S,_,E),o(G,_,E),m(h.children,_,G,T,P,j,A,M)):K>0&&K&64&&Z&&d.dynamicChildren?(B(d.dynamicChildren,Z,_,T,P,j,A),(h.key!=null||T&&h===T.subTree)&&gl(d,h,!0)):ie(d,h,_,G,T,P,j,A,M)},R=(d,h,_,E,T,P,j,A,M)=>{h.slotScopeIds=A,d==null?h.shapeFlag&512?T.ctx.activate(h,_,E,j,M):D(h,_,E,T,P,j,M):le(d,h,M)},D=(d,h,_,E,T,P,j)=>{const A=d.component=mu(d,E,T);if(Wn(d)&&(A.ctx.renderer=$),vu(A),A.asyncDep){if(T&&T.registerDep(A,U),!d.el){const M=A.subTree=ne(Ye);g(null,M,h,_)}return}U(A,d,h,_,T,P,j)},le=(d,h,_)=>{const E=h.component=d.component;if(Pc(d,h,_))if(E.asyncDep&&!E.asyncResolved){se(E,h,_);return}else E.next=h,bc(E.update),E.update();else h.el=d.el,E.vnode=h},U=(d,h,_,E,T,P,j)=>{const A=()=>{if(d.isMounted){let{next:G,bu:K,u:Z,parent:oe,vnode:ce}=d,we=G,_e;Ht(d,!1),G?(G.el=ce.el,se(d,G,j)):G=ce,K&&fo(K),(_e=G.props&&G.props.onVnodeBeforeUpdate)&&Ze(_e,oe,G,ce),Ht(d,!0);const Pe=Ko(d),tt=d.subTree;d.subTree=Pe,w(tt,Pe,f(tt.el),C(tt),d,T,P),G.el=Pe.el,we===null&&Oc(d,Pe.el),Z&&Ke(Z,T),(_e=G.props&&G.props.onVnodeUpdated)&&Ke(()=>Ze(_e,oe,G,ce),T)}else{let G;const{el:K,props:Z}=h,{bm:oe,m:ce,parent:we}=d,_e=cn(h);if(Ht(d,!1),oe&&fo(oe),!_e&&(G=Z&&Z.onVnodeBeforeMount)&&Ze(G,we,h),Ht(d,!0),K&&ue){const Pe=()=>{d.subTree=Ko(d),ue(K,d.subTree,d,T,null)};_e?h.type.__asyncLoader().then(()=>!d.isUnmounted&&Pe()):Pe()}else{const Pe=d.subTree=Ko(d);w(null,Pe,_,E,d,T,P),h.el=Pe.el}if(ce&&Ke(ce,T),!_e&&(G=Z&&Z.onVnodeMounted)){const Pe=h;Ke(()=>Ze(G,we,Pe),T)}(h.shapeFlag&256||we&&cn(we.vnode)&&we.vnode.shapeFlag&256)&&d.a&&Ke(d.a,T),d.isMounted=!0,h=_=E=null}},M=d.effect=new Lr(A,()=>$o(S),d.scope),S=d.update=()=>M.run();S.id=d.uid,Ht(d,!0),S()},se=(d,h,_)=>{h.component=d;const E=d.vnode.props;d.vnode=h,d.next=null,eu(d,h.props,E,_),ou(d,h.children,_),vn(),ps(),gn()},ie=(d,h,_,E,T,P,j,A,M=!1)=>{const S=d&&d.children,G=d?d.shapeFlag:0,K=h.children,{patchFlag:Z,shapeFlag:oe}=h;if(Z>0){if(Z&128){$e(S,K,_,E,T,P,j,A,M);return}else if(Z&256){He(S,K,_,E,T,P,j,A,M);return}}oe&8?(G&16&&De(S,T,P),K!==S&&u(_,K)):G&16?oe&16?$e(S,K,_,E,T,P,j,A,M):De(S,T,P,!0):(G&8&&u(_,""),oe&16&&m(K,_,E,T,P,j,A,M))},He=(d,h,_,E,T,P,j,A,M)=>{d=d||rn,h=h||rn;const S=d.length,G=h.length,K=Math.min(S,G);let Z;for(Z=0;ZG?De(d,T,P,!0,!1,K):m(h,_,E,T,P,j,A,M,K)},$e=(d,h,_,E,T,P,j,A,M)=>{let S=0;const G=h.length;let K=d.length-1,Z=G-1;for(;S<=K&&S<=Z;){const oe=d[S],ce=h[S]=M?kt(h[S]):nt(h[S]);if(zt(oe,ce))w(oe,ce,_,null,T,P,j,A,M);else break;S++}for(;S<=K&&S<=Z;){const oe=d[K],ce=h[Z]=M?kt(h[Z]):nt(h[Z]);if(zt(oe,ce))w(oe,ce,_,null,T,P,j,A,M);else break;K--,Z--}if(S>K){if(S<=Z){const oe=Z+1,ce=oeZ)for(;S<=K;)Be(d[S],T,P,!0),S++;else{const oe=S,ce=S,we=new Map;for(S=ce;S<=Z;S++){const Je=h[S]=M?kt(h[S]):nt(h[S]);Je.key!=null&&we.set(Je.key,S)}let _e,Pe=0;const tt=Z-ce+1;let Xt=!1,es=0;const bn=new Array(tt);for(S=0;S=tt){Be(Je,T,P,!0);continue}let at;if(Je.key!=null)at=we.get(Je.key);else for(_e=ce;_e<=Z;_e++)if(bn[_e-ce]===0&&zt(Je,h[_e])){at=_e;break}at===void 0?Be(Je,T,P,!0):(bn[at-ce]=S+1,at>=es?es=at:Xt=!0,w(Je,h[at],_,null,T,P,j,A,M),Pe++)}const ts=Xt?lu(bn):rn;for(_e=ts.length-1,S=tt-1;S>=0;S--){const Je=ce+S,at=h[Je],ns=Je+1{const{el:P,type:j,transition:A,children:M,shapeFlag:S}=d;if(S&6){Ue(d.component.subTree,h,_,E);return}if(S&128){d.suspense.move(h,_,E);return}if(S&64){j.move(d,h,_,$);return}if(j===Ee){o(P,h,_);for(let K=0;KA.enter(P),T);else{const{leave:K,delayLeave:Z,afterLeave:oe}=A,ce=()=>o(P,h,_),we=()=>{K(P,()=>{ce(),oe&&oe()})};Z?Z(P,ce,we):we()}else o(P,h,_)},Be=(d,h,_,E=!1,T=!1)=>{const{type:P,props:j,ref:A,children:M,dynamicChildren:S,shapeFlag:G,patchFlag:K,dirs:Z}=d;if(A!=null&&wo(A,null,_,d,!0),G&256){h.ctx.deactivate(d);return}const oe=G&1&&Z,ce=!cn(d);let we;if(ce&&(we=j&&j.onVnodeBeforeUnmount)&&Ze(we,h,d),G&6)lt(d.component,_,E);else{if(G&128){d.suspense.unmount(_,E);return}oe&&ct(d,null,h,"beforeUnmount"),G&64?d.type.remove(d,h,_,T,$,E):S&&(P!==Ee||K>0&&K&64)?De(S,h,_,!1,!0):(P===Ee&&K&384||!T&&G&16)&&De(M,h,_),E&&Tt(d)}(ce&&(we=j&&j.onVnodeUnmounted)||oe)&&Ke(()=>{we&&Ze(we,h,d),oe&&ct(d,null,h,"unmounted")},_)},Tt=d=>{const{type:h,el:_,anchor:E,transition:T}=d;if(h===Ee){Lt(_,E);return}if(h===On){k(d);return}const P=()=>{r(_),T&&!T.persisted&&T.afterLeave&&T.afterLeave()};if(d.shapeFlag&1&&T&&!T.persisted){const{leave:j,delayLeave:A}=T,M=()=>j(_,P);A?A(d.el,P,M):M()}else P()},Lt=(d,h)=>{let _;for(;d!==h;)_=p(d),r(d),d=_;r(h)},lt=(d,h,_)=>{const{bum:E,scope:T,update:P,subTree:j,um:A}=d;E&&fo(E),T.stop(),P&&(P.active=!1,Be(j,d,h,_)),A&&Ke(A,h),Ke(()=>{d.isUnmounted=!0},h),h&&h.pendingBranch&&!h.isUnmounted&&d.asyncDep&&!d.asyncResolved&&d.suspenseId===h.pendingId&&(h.deps--,h.deps===0&&h.resolve())},De=(d,h,_,E=!1,T=!1,P=0)=>{for(let j=P;jd.shapeFlag&6?C(d.component.subTree):d.shapeFlag&128?d.suspense.next():p(d.anchor||d.el),V=(d,h,_)=>{d==null?h._vnode&&Be(h._vnode,null,null,!0):w(h._vnode||null,d,h,null,null,null,_),ps(),_o(),h._vnode=d},$={p:w,um:Be,m:Ue,r:Tt,mt:D,mc:m,pc:ie,pbc:B,n:C,o:e};let J,ue;return t&&([J,ue]=t($)),{render:V,hydrate:J,createApp:Zc(V,J)}}function Ht({effect:e,update:t},n){e.allowRecurse=t.allowRecurse=n}function gl(e,t,n=!1){const o=e.children,r=t.children;if(q(o)&&q(r))for(let s=0;s>1,e[n[l]]0&&(t[o]=n[s-1]),n[s]=o)}}for(s=n.length,i=n[s-1];s-- >0;)n[s]=i,i=t[i];return n}const au=e=>e.__isTeleport,Ee=Symbol.for("v-fgt"),fn=Symbol.for("v-txt"),Ye=Symbol.for("v-cmt"),On=Symbol.for("v-stc"),Sn=[];let rt=null;function H(e=!1){Sn.push(rt=e?null:[])}function cu(){Sn.pop(),rt=Sn[Sn.length-1]||null}let Bn=1;function Ls(e){Bn+=e}function _l(e){return e.dynamicChildren=Bn>0?rt||rn:null,cu(),Bn>0&&rt&&rt.push(e),e}function Q(e,t,n,o,r,s){return _l(ae(e,t,n,o,r,s,!0))}function Re(e,t,n,o,r){return _l(ne(e,t,n,o,r,!0))}function Co(e){return e?e.__v_isVNode===!0:!1}function zt(e,t){return e.type===t.type&&e.key===t.key}const jo="__vInternal",bl=({key:e})=>e??null,po=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?me(e)||Ae(e)||re(e)?{i:Ne,r:e,k:t,f:!!n}:e:null);function ae(e,t=null,n=null,o=0,r=null,s=e===Ee?0:1,i=!1,l=!1){const a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&bl(t),ref:t&&po(t),scopeId:No,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetAnchor:null,staticCount:0,shapeFlag:s,patchFlag:o,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Ne};return l?(Br(a,n),s&128&&e.normalize(a)):n&&(a.shapeFlag|=me(n)?8:16),Bn>0&&!i&&rt&&(a.patchFlag>0||s&6)&&a.patchFlag!==32&&rt.push(a),a}const ne=uu;function uu(e,t=null,n=null,o=0,r=null,s=!1){if((!e||e===zc)&&(e=Ye),Co(e)){const l=Nt(e,t,!0);return n&&Br(l,n),Bn>0&&!s&&rt&&(l.shapeFlag&6?rt[rt.indexOf(e)]=l:rt.push(l)),l.patchFlag|=-2,l}if(Eu(e)&&(e=e.__vccOpts),t){t=fu(t);let{class:l,style:a}=t;l&&!me(l)&&(t.class=ze(l)),ye(a)&&(Ui(a)&&!q(a)&&(a=Se({},a)),t.style=Qt(a))}const i=me(e)?1:Sc(e)?128:au(e)?64:ye(e)?4:re(e)?2:0;return ae(e,t,n,o,r,i,s,!0)}function fu(e){return e?Ui(e)||jo in e?Se({},e):e:null}function Nt(e,t,n=!1){const{props:o,ref:r,patchFlag:s,children:i}=e,l=t?hr(o||{},t):o;return{__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&bl(l),ref:t&&t.ref?n&&r?q(r)?r.concat(po(t)):[r,po(t)]:po(t):r,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:i,target:e.target,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Ee?s===-1?16:s|16:s,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:e.transition,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Nt(e.ssContent),ssFallback:e.ssFallback&&Nt(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce}}function Et(e=" ",t=0){return ne(fn,null,e,t)}function du(e,t){const n=ne(On,null,e);return n.staticCount=t,n}function xe(e="",t=!1){return t?(H(),Re(Ye,null,e)):ne(Ye,null,e)}function nt(e){return e==null||typeof e=="boolean"?ne(Ye):q(e)?ne(Ee,null,e.slice()):typeof e=="object"?kt(e):ne(fn,null,String(e))}function kt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Nt(e)}function Br(e,t){let n=0;const{shapeFlag:o}=e;if(t==null)t=null;else if(q(t))n=16;else if(typeof t=="object")if(o&65){const r=t.default;r&&(r._c&&(r._d=!1),Br(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!(jo in t)?t._ctx=Ne:r===3&&Ne&&(Ne.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else re(t)?(t={default:t,_ctx:Ne},n=32):(t=String(t),o&64?(n=16,t=[Et(t)]):n=8);e.children=t,e.shapeFlag|=n}function hr(...e){const t={};for(let n=0;nke||Ne;let jr,en,xs="__VUE_INSTANCE_SETTERS__";(en=or()[xs])||(en=or()[xs]=[]),en.push(e=>ke=e),jr=e=>{en.length>1?en.forEach(t=>t(e)):en[0](e)};const dn=e=>{jr(e),e.scope.on()},Jt=()=>{ke&&ke.scope.off(),jr(null)};function El(e){return e.vnode.shapeFlag&4}let pn=!1;function vu(e,t=!1){pn=t;const{props:n,children:o}=e.vnode,r=El(e);Xc(e,n,r,t),nu(e,o);const s=r?gu(e,t):void 0;return pn=!1,s}function gu(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=Ki(new Proxy(e.ctx,Kc));const{setup:o}=n;if(o){const r=e.setupContext=o.length>1?bu(e):null;dn(e),vn();const s=Dt(o,e,0,[e.props,r]);if(gn(),Jt(),xi(s)){if(s.then(Jt,Jt),t)return s.then(i=>{Ps(e,i,t)}).catch(i=>{qn(i,e,0)});e.asyncDep=s}else Ps(e,s,t)}else wl(e,t)}function Ps(e,t,n){re(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ye(t)&&(e.setupState=Wi(t)),wl(e,n)}let Os;function wl(e,t,n){const o=e.type;if(!e.render){if(!t&&Os&&!o.render){const r=o.template||Nr(e).template;if(r){const{isCustomElement:s,compilerOptions:i}=e.appContext.config,{delimiters:l,compilerOptions:a}=o,c=Se(Se({isCustomElement:s,delimiters:l},i),a);o.render=Os(r,c)}}e.render=o.render||st}dn(e),vn(),qc(e),gn(),Jt()}function _u(e){return e.attrsProxy||(e.attrsProxy=new Proxy(e.attrs,{get(t,n){return qe(e,"get","$attrs"),t[n]}}))}function bu(e){const t=n=>{e.exposed=n||{}};return{get attrs(){return _u(e)},slots:e.slots,emit:e.emit,expose:t}}function Vo(e){if(e.exposed)return e.exposeProxy||(e.exposeProxy=new Proxy(Wi(Ki(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Pn)return Pn[n](e)},has(t,n){return n in t||n in Pn}}))}function yu(e,t=!0){return re(e)?e.displayName||e.name:e.name||t&&e.__name}function Eu(e){return re(e)&&"__vccOpts"in e}const z=(e,t)=>vc(e,t,pn);function ge(e,t,n){const o=arguments.length;return o===2?ye(t)&&!q(t)?Co(t)?ne(e,null,[t]):ne(e,t):ne(e,null,t):(o>3?n=Array.prototype.slice.call(arguments,2):o===3&&Co(n)&&(n=[n]),ne(e,t,n))}const wu=Symbol.for("v-scx"),Cu=()=>Oe(wu),Tu="3.3.4",Lu="http://www.w3.org/2000/svg",Ut=typeof document<"u"?document:null,Ss=Ut&&Ut.createElement("template"),xu={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,o)=>{const r=t?Ut.createElementNS(Lu,e):Ut.createElement(e,n?{is:n}:void 0);return e==="select"&&o&&o.multiple!=null&&r.setAttribute("multiple",o.multiple),r},createText:e=>Ut.createTextNode(e),createComment:e=>Ut.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ut.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,o,r,s){const i=n?n.previousSibling:t.lastChild;if(r&&(r===s||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===s||!(r=r.nextSibling)););else{Ss.innerHTML=o?`${e}`:e;const l=Ss.content;if(o){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}};function Pu(e,t,n){const o=e._vtc;o&&(t=(t?[t,...o]:[...o]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}function Ou(e,t,n){const o=e.style,r=me(n);if(n&&!r){if(t&&!me(t))for(const s in t)n[s]==null&&mr(o,s,"");for(const s in n)mr(o,s,n[s])}else{const s=o.display;r?t!==n&&(o.cssText=n):t&&e.removeAttribute("style"),"_vod"in e&&(o.display=s)}}const ks=/\s*!important$/;function mr(e,t,n){if(q(n))n.forEach(o=>mr(e,t,o));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const o=Su(e,t);ks.test(n)?e.setProperty(Yt(o),n.replace(ks,""),"important"):e[o]=n}}const Is=["Webkit","Moz","ms"],Qo={};function Su(e,t){const n=Qo[t];if(n)return n;let o=ft(t);if(o!=="filter"&&o in e)return Qo[t]=o;o=Io(o);for(let r=0;rYo||($u.then(()=>Yo=0),Yo=Date.now());function Nu(e,t){const n=o=>{if(!o._vts)o._vts=Date.now();else if(o._vts<=n.attached)return;Xe(Hu(o,n.value),t,5,[o])};return n.value=e,n.attached=Mu(),n}function Hu(e,t){if(q(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(o=>r=>!r._stopped&&o&&o(r))}else return t}const Ds=/^on[a-z]/,Bu=(e,t,n,o,r=!1,s,i,l,a)=>{t==="class"?Pu(e,o,r):t==="style"?Ou(e,n,o):zn(t)?Er(t)||Ru(e,t,n,o,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):ju(e,t,o,r))?Iu(e,t,o,s,i,l,a):(t==="true-value"?e._trueValue=o:t==="false-value"&&(e._falseValue=o),ku(e,t,o,r))};function ju(e,t,n,o){return o?!!(t==="innerHTML"||t==="textContent"||t in e&&Ds.test(t)&&re(n)):t==="spellcheck"||t==="draggable"||t==="translate"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA"||Ds.test(t)&&me(n)?!1:t in e}const Ot="transition",yn="animation",Qn=(e,{slots:t})=>ge(Rc,Vu(e),t);Qn.displayName="Transition";const Tl={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String};Qn.props=Se({},nl,Tl);const Bt=(e,t=[])=>{q(e)?e.forEach(n=>n(...t)):e&&e(...t)},$s=e=>e?q(e)?e.some(t=>t.length>1):e.length>1:!1;function Vu(e){const t={};for(const L in e)L in Tl||(t[L]=e[L]);if(e.css===!1)return t;const{name:n="v",type:o,duration:r,enterFromClass:s=`${n}-enter-from`,enterActiveClass:i=`${n}-enter-active`,enterToClass:l=`${n}-enter-to`,appearFromClass:a=s,appearActiveClass:c=i,appearToClass:u=l,leaveFromClass:f=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:v=`${n}-leave-to`}=e,y=Fu(r),w=y&&y[0],x=y&&y[1],{onBeforeEnter:g,onEnter:b,onEnterCancelled:I,onLeave:k,onLeaveCancelled:W,onBeforeAppear:ee=g,onAppear:N=b,onAppearCancelled:m=I}=t,F=(L,R,D)=>{jt(L,R?u:l),jt(L,R?c:i),D&&D()},B=(L,R)=>{L._isLeaving=!1,jt(L,f),jt(L,v),jt(L,p),R&&R()},Y=L=>(R,D)=>{const le=L?N:b,U=()=>F(R,L,D);Bt(le,[R,U]),Ms(()=>{jt(R,L?a:s),St(R,L?u:l),$s(le)||Ns(R,o,w,U)})};return Se(t,{onBeforeEnter(L){Bt(g,[L]),St(L,s),St(L,i)},onBeforeAppear(L){Bt(ee,[L]),St(L,a),St(L,c)},onEnter:Y(!1),onAppear:Y(!0),onLeave(L,R){L._isLeaving=!0;const D=()=>B(L,R);St(L,f),Ku(),St(L,p),Ms(()=>{L._isLeaving&&(jt(L,f),St(L,v),$s(k)||Ns(L,o,x,D))}),Bt(k,[L,D])},onEnterCancelled(L){F(L,!1),Bt(I,[L])},onAppearCancelled(L){F(L,!0),Bt(m,[L])},onLeaveCancelled(L){B(L),Bt(W,[L])}})}function Fu(e){if(e==null)return null;if(ye(e))return[Go(e.enter),Go(e.leave)];{const t=Go(e);return[t,t]}}function Go(e){return Ca(e)}function St(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e._vtc||(e._vtc=new Set)).add(t)}function jt(e,t){t.split(/\s+/).forEach(o=>o&&e.classList.remove(o));const{_vtc:n}=e;n&&(n.delete(t),n.size||(e._vtc=void 0))}function Ms(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let zu=0;function Ns(e,t,n,o){const r=e._endId=++zu,s=()=>{r===e._endId&&o()};if(n)return setTimeout(s,n);const{type:i,timeout:l,propCount:a}=Uu(e,t);if(!i)return o();const c=i+"end";let u=0;const f=()=>{e.removeEventListener(c,p),s()},p=v=>{v.target===e&&++u>=a&&f()};setTimeout(()=>{u(n[y]||"").split(", "),r=o(`${Ot}Delay`),s=o(`${Ot}Duration`),i=Hs(r,s),l=o(`${yn}Delay`),a=o(`${yn}Duration`),c=Hs(l,a);let u=null,f=0,p=0;t===Ot?i>0&&(u=Ot,f=i,p=s.length):t===yn?c>0&&(u=yn,f=c,p=a.length):(f=Math.max(i,c),u=f>0?i>c?Ot:yn:null,p=u?u===Ot?s.length:a.length:0);const v=u===Ot&&/\b(transform|all)(,|$)/.test(o(`${Ot}Property`).toString());return{type:u,timeout:f,propCount:p,hasTransform:v}}function Hs(e,t){for(;e.lengthBs(n)+Bs(e[o])))}function Bs(e){return Number(e.slice(0,-1).replace(",","."))*1e3}function Ku(){return document.body.offsetHeight}const js=e=>{const t=e.props["onUpdate:modelValue"]||!1;return q(t)?n=>fo(t,n):t},qu={deep:!0,created(e,{value:t,modifiers:{number:n}},o){const r=So(t);Cl(e,"change",()=>{const s=Array.prototype.filter.call(e.options,i=>i.selected).map(i=>n?Si(To(i)):To(i));e._assign(e.multiple?r?new Set(s):s:s[0])}),e._assign=js(o)},mounted(e,{value:t}){Vs(e,t)},beforeUpdate(e,t,n){e._assign=js(n)},updated(e,{value:t}){Vs(e,t)}};function Vs(e,t){const n=e.multiple;if(!(n&&!q(t)&&!So(t))){for(let o=0,r=e.options.length;o-1:s.selected=t.has(i);else if(Ao(To(s),t)){e.selectedIndex!==o&&(e.selectedIndex=o);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function To(e){return"_value"in e?e._value:e.value}const Wu={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},Ju=(e,t)=>n=>{if(!("key"in n))return;const o=Yt(n.key);if(t.some(r=>r===o||Wu[r]===o))return e(n)},Lo={beforeMount(e,{value:t},{transition:n}){e._vod=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):En(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:o}){!t!=!n&&(o?t?(o.beforeEnter(e),En(e,!0),o.enter(e)):o.leave(e,()=>{En(e,!1)}):En(e,t))},beforeUnmount(e,{value:t}){En(e,t)}};function En(e,t){e.style.display=t?e._vod:"none"}const Ll=Se({patchProp:Bu},xu);let kn,Fs=!1;function Qu(){return kn||(kn=su(Ll))}function Yu(){return kn=Fs?kn:iu(Ll),Fs=!0,kn}const Gu=(...e)=>{const t=Qu().createApp(...e),{mount:n}=t;return t.mount=o=>{const r=xl(o);if(!r)return;const s=t._component;!re(s)&&!s.render&&!s.template&&(s.template=r.innerHTML),r.innerHTML="";const i=n(r,!1,r instanceof SVGElement);return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),i},t},Zu=(...e)=>{const t=Yu().createApp(...e),{mount:n}=t;return t.mount=o=>{const r=xl(o);if(r)return n(r,!0,r instanceof SVGElement)},t};function xl(e){return me(e)?document.querySelector(e):e}const Xu={"v-8daa1a0e":()=>O(()=>import("./index.html-692e45cb.js"),[]).then(({data:e})=>e),"v-14ac19b5":()=>O(()=>import("./index.html-7d2ae716.js"),[]).then(({data:e})=>e),"v-e5065f60":()=>O(()=>import("./index.html-7dc4890d.js"),[]).then(({data:e})=>e),"v-eb8745ce":()=>O(()=>import("./index.html-15fde261.js"),[]).then(({data:e})=>e),"v-828730c2":()=>O(()=>import("./index.html-93ff6c80.js"),[]).then(({data:e})=>e),"v-563ef996":()=>O(()=>import("./index.html-09f857ca.js"),[]).then(({data:e})=>e),"v-04b96fc8":()=>O(()=>import("./index.html-70c61dd5.js"),[]).then(({data:e})=>e),"v-fe45a0b8":()=>O(()=>import("./index.html-a7337040.js"),[]).then(({data:e})=>e),"v-7ed00a2a":()=>O(()=>import("./index.html-f8b7a659.js"),[]).then(({data:e})=>e),"v-326db923":()=>O(()=>import("./index.html-ced62edd.js"),[]).then(({data:e})=>e),"v-587df7db":()=>O(()=>import("./index.html-88fcd172.js"),[]).then(({data:e})=>e),"v-0b2aad78":()=>O(()=>import("./index.html-a2d4ab5c.js"),[]).then(({data:e})=>e),"v-23f62a3a":()=>O(()=>import("./index.html-6b858d80.js"),[]).then(({data:e})=>e),"v-1963670f":()=>O(()=>import("./index.html-549ff1f9.js"),[]).then(({data:e})=>e),"v-4cf2565c":()=>O(()=>import("./index.html-6e94f439.js"),[]).then(({data:e})=>e),"v-17bdcfe6":()=>O(()=>import("./index.html-c46be575.js"),[]).then(({data:e})=>e),"v-3481b484":()=>O(()=>import("./index.html-a70b6cf1.js"),[]).then(({data:e})=>e),"v-b6a1f058":()=>O(()=>import("./index.html-a9eb55a1.js"),[]).then(({data:e})=>e),"v-94b7dab4":()=>O(()=>import("./index.html-f9768eb1.js"),[]).then(({data:e})=>e),"v-391365f4":()=>O(()=>import("./index.html-6bb621c5.js"),[]).then(({data:e})=>e),"v-32b5e2dd":()=>O(()=>import("./index.html-bc520f9b.js"),[]).then(({data:e})=>e),"v-02a19d2b":()=>O(()=>import("./index.html-42466a4a.js"),[]).then(({data:e})=>e),"v-26e624c6":()=>O(()=>import("./index.html-3bc7b1c6.js"),[]).then(({data:e})=>e),"v-6165843c":()=>O(()=>import("./index.html-caa38aac.js"),[]).then(({data:e})=>e),"v-22e054a1":()=>O(()=>import("./index.html-34c15120.js"),[]).then(({data:e})=>e),"v-147825fb":()=>O(()=>import("./index.html-2fb78bd6.js"),[]).then(({data:e})=>e),"v-255f131a":()=>O(()=>import("./index.html-963a4ecf.js"),[]).then(({data:e})=>e),"v-6768263b":()=>O(()=>import("./index.html-1d87c569.js"),[]).then(({data:e})=>e),"v-6a4de75f":()=>O(()=>import("./index.html-a0e9a4ad.js"),[]).then(({data:e})=>e),"v-a20dfce8":()=>O(()=>import("./index.html-76d114a5.js"),[]).then(({data:e})=>e),"v-9a955b1e":()=>O(()=>import("./index.html-e1863c04.js"),[]).then(({data:e})=>e),"v-ca3407c4":()=>O(()=>import("./index.html-fae03536.js"),[]).then(({data:e})=>e),"v-3cf0fa66":()=>O(()=>import("./index.html-6ca7c7e1.js"),[]).then(({data:e})=>e),"v-b09aba04":()=>O(()=>import("./index.html-568e376b.js"),[]).then(({data:e})=>e),"v-b341ee2c":()=>O(()=>import("./index.html-ccd9b97c.js"),[]).then(({data:e})=>e),"v-0c9564ec":()=>O(()=>import("./index.html-a48fd168.js"),[]).then(({data:e})=>e),"v-36e2ae9d":()=>O(()=>import("./index.html-c67caa0a.js"),[]).then(({data:e})=>e),"v-5b6d532c":()=>O(()=>import("./index.html-9094b141.js"),[]).then(({data:e})=>e),"v-9712b6e4":()=>O(()=>import("./index.html-602c2540.js"),[]).then(({data:e})=>e),"v-bdba93e6":()=>O(()=>import("./filterQueryParam.html-d51cd871.js"),[]).then(({data:e})=>e),"v-31ddcbc0":()=>O(()=>import("./filterQueryParamCode.html-293978be.js"),[]).then(({data:e})=>e),"v-0e79de1b":()=>O(()=>import("./filterQueryParamExample.html-c90c301f.js"),[]).then(({data:e})=>e),"v-9a1a7988":()=>O(()=>import("./jmespathFilter.html-7c48b0c1.js"),[]).then(({data:e})=>e),"v-4395d380":()=>O(()=>import("./throttle.html-4a67c62e.js"),[]).then(({data:e})=>e),"v-17bf8008":()=>O(()=>import("./whereQueryParam.html-10f3d62d.js"),[]).then(({data:e})=>e),"v-0b4a148f":()=>O(()=>import("./whereQueryParamCode.html-33176c09.js"),[]).then(({data:e})=>e),"v-5119194c":()=>O(()=>import("./whereQueryParamExample.html-68248e1c.js"),[]).then(({data:e})=>e),"v-3706649a":()=>O(()=>import("./404.html-60b35caa.js"),[]).then(({data:e})=>e)},ef=JSON.parse('{"base":"/NotifyBC/preview/","lang":"en-US","title":"NotifyBC","description":"A versatile notification API server","head":[["meta",{"name":"theme-color","content":"#3eaf7c"}],["meta",{"name":"apple-mobile-web-app-capable","content":"yes"}],["meta",{"name":"apple-mobile-web-app-status-bar-style","content":"black"}],["link",{"rel":"icon","type":"image/x-icon","href":"/NotifyBC/preview/favicon.ico"}],["link",{"rel":"stylesheet","href":"https://fonts.googleapis.com/icon?family=Material+Icons"}]],"locales":{}}');var tf=([e,t,n])=>e==="meta"&&t.name?`${e}.${t.name}`:["title","base"].includes(e)?e:e==="template"&&t.id?`${e}.${t.id}`:JSON.stringify([e,t,n]),nf=e=>{const t=new Set,n=[];return e.forEach(o=>{const r=tf(o);t.has(r)||(t.add(r),n.push(o))}),n},Yn=e=>/^(https?:)?\/\//.test(e),of=e=>/^mailto:/.test(e),rf=e=>/^tel:/.test(e),Vr=e=>Object.prototype.toString.call(e)==="[object Object]",Pl=e=>e[e.length-1]==="/"?e.slice(0,-1):e,Ol=e=>e[0]==="/"?e.slice(1):e,Sl=(e,t)=>{const n=Object.keys(e).sort((o,r)=>{const s=r.split("/").length-o.split("/").length;return s!==0?s:r.length-o.length});for(const o of n)if(t.startsWith(o))return o;return"/"},zs=(e,t="/")=>{const n=e.replace(/^(https?:)?\/\/[^/]*/,"");return n.startsWith(t)?`/${n.slice(t.length)}`:n};const kl={"v-8daa1a0e":te(()=>O(()=>import("./index.html-a223bf40.js"),[])),"v-14ac19b5":te(()=>O(()=>import("./index.html-59271dfc.js"),[])),"v-e5065f60":te(()=>O(()=>import("./index.html-1d68b11c.js"),[])),"v-eb8745ce":te(()=>O(()=>import("./index.html-94f1d832.js"),[])),"v-828730c2":te(()=>O(()=>import("./index.html-ddb984f8.js"),[])),"v-563ef996":te(()=>O(()=>import("./index.html-40029d88.js"),[])),"v-04b96fc8":te(()=>O(()=>import("./index.html-b4c829a8.js"),[])),"v-fe45a0b8":te(()=>O(()=>import("./index.html-fd34968b.js"),[])),"v-7ed00a2a":te(()=>O(()=>import("./index.html-6e427c03.js"),[])),"v-326db923":te(()=>O(()=>import("./index.html-e67b9cd4.js"),[])),"v-587df7db":te(()=>O(()=>import("./index.html-1ae75d86.js"),[])),"v-0b2aad78":te(()=>O(()=>import("./index.html-a08056c0.js"),[])),"v-23f62a3a":te(()=>O(()=>import("./index.html-36d5ffa2.js"),[])),"v-1963670f":te(()=>O(()=>import("./index.html-8f681a20.js"),[])),"v-4cf2565c":te(()=>O(()=>import("./index.html-7f453789.js"),[])),"v-17bdcfe6":te(()=>O(()=>import("./index.html-854328d1.js"),[])),"v-3481b484":te(()=>O(()=>import("./index.html-3a8f0d9c.js"),[])),"v-b6a1f058":te(()=>O(()=>import("./index.html-671e4912.js"),[])),"v-94b7dab4":te(()=>O(()=>import("./index.html-47b50680.js"),[])),"v-391365f4":te(()=>O(()=>import("./index.html-284021a2.js"),[])),"v-32b5e2dd":te(()=>O(()=>import("./index.html-8a07e160.js"),[])),"v-02a19d2b":te(()=>O(()=>import("./index.html-8ad94fae.js"),[])),"v-26e624c6":te(()=>O(()=>import("./index.html-5afa062f.js"),[])),"v-6165843c":te(()=>O(()=>import("./index.html-27f35336.js"),[])),"v-22e054a1":te(()=>O(()=>import("./index.html-4f4b887a.js"),[])),"v-147825fb":te(()=>O(()=>import("./index.html-585cce7b.js"),[])),"v-255f131a":te(()=>O(()=>import("./index.html-e44840e5.js"),[])),"v-6768263b":te(()=>O(()=>import("./index.html-797fb107.js"),[])),"v-6a4de75f":te(()=>O(()=>import("./index.html-06effaf7.js"),[])),"v-a20dfce8":te(()=>O(()=>import("./index.html-233fe616.js"),[])),"v-9a955b1e":te(()=>O(()=>import("./index.html-7b20a00b.js"),[])),"v-ca3407c4":te(()=>O(()=>import("./index.html-4edd1034.js"),[])),"v-3cf0fa66":te(()=>O(()=>import("./index.html-5b46e0a4.js"),[])),"v-b09aba04":te(()=>O(()=>import("./index.html-4a529ad3.js"),[])),"v-b341ee2c":te(()=>O(()=>import("./index.html-341d16d7.js"),[])),"v-0c9564ec":te(()=>O(()=>import("./index.html-43ad52c1.js"),[])),"v-36e2ae9d":te(()=>O(()=>import("./index.html-d1326e32.js"),[])),"v-5b6d532c":te(()=>O(()=>import("./index.html-bca8604a.js"),[])),"v-9712b6e4":te(()=>O(()=>import("./index.html-7af3bcd3.js"),[])),"v-bdba93e6":te(()=>O(()=>import("./filterQueryParam.html-428a3252.js"),[])),"v-31ddcbc0":te(()=>O(()=>import("./filterQueryParamCode.html-18ac53c8.js"),[])),"v-0e79de1b":te(()=>O(()=>import("./filterQueryParamExample.html-95d3a481.js"),[])),"v-9a1a7988":te(()=>O(()=>import("./jmespathFilter.html-35e45519.js"),[])),"v-4395d380":te(()=>O(()=>import("./throttle.html-32dcc936.js"),[])),"v-17bf8008":te(()=>O(()=>import("./whereQueryParam.html-889e420d.js"),[])),"v-0b4a148f":te(()=>O(()=>import("./whereQueryParamCode.html-84587f03.js"),[])),"v-5119194c":te(()=>O(()=>import("./whereQueryParamExample.html-10f69adc.js"),[])),"v-3706649a":te(()=>O(()=>import("./404.html-6024058c.js"),[]))};var sf=Symbol(""),lf=be(Xu),Il=_n({key:"",path:"",title:"",lang:"",frontmatter:{},headers:[]}),It=be(Il),$t=()=>It,Al=Symbol(""),vt=()=>{const e=Oe(Al);if(!e)throw new Error("usePageFrontmatter() is called without provider.");return e},Rl=Symbol(""),af=()=>{const e=Oe(Rl);if(!e)throw new Error("usePageHead() is called without provider.");return e},cf=Symbol(""),Dl=Symbol(""),$l=()=>{const e=Oe(Dl);if(!e)throw new Error("usePageLang() is called without provider.");return e},Ml=Symbol(""),uf=()=>{const e=Oe(Ml);if(!e)throw new Error("usePageLayout() is called without provider.");return e},Fr=Symbol(""),Gn=()=>{const e=Oe(Fr);if(!e)throw new Error("useRouteLocale() is called without provider.");return e},on=be(ef),Nl=()=>on,Hl=Symbol(""),zr=()=>{const e=Oe(Hl);if(!e)throw new Error("useSiteLocaleData() is called without provider.");return e},ff=Symbol(""),df="Layout",pf="NotFound",pt=Kn({resolveLayouts:e=>e.reduce((t,n)=>({...t,...n.layouts}),{}),resolvePageData:async e=>{const t=lf.value[e];return await(t==null?void 0:t())??Il},resolvePageFrontmatter:e=>e.frontmatter,resolvePageHead:(e,t,n)=>{const o=me(t.description)?t.description:n.description,r=[...q(t.head)?t.head:[],...n.head,["title",{},e],["meta",{name:"description",content:o}]];return nf(r)},resolvePageHeadTitle:(e,t)=>[e.title,t.title].filter(n=>!!n).join(" | "),resolvePageLang:(e,t)=>e.lang||t.lang||"en-US",resolvePageLayout:(e,t)=>{let n;if(e.path){const o=e.frontmatter.layout;me(o)?n=o:n=df}else n=pf;return t[n]},resolveRouteLocale:(e,t)=>Sl(e,t),resolveSiteLocaleData:(e,t)=>({...e,...e.locales[t]})}),Ur=fe({name:"ClientOnly",setup(e,t){const n=be(!1);return We(()=>{n.value=!0}),()=>{var o,r;return n.value?(r=(o=t.slots).default)==null?void 0:r.call(o):null}}}),hf=fe({name:"Content",props:{pageKey:{type:String,required:!1,default:""}},setup(e){const t=$t(),n=z(()=>kl[e.pageKey||t.value.key]);return()=>n.value?ge(n.value):ge("div","404 Not Found")}}),Ct=(e={})=>e,Kr=e=>Yn(e)?e:`/NotifyBC/preview/${Ol(e)}`;function qr(e,t,n){var o,r,s;t===void 0&&(t=50),n===void 0&&(n={});var i=(o=n.isImmediate)!=null&&o,l=(r=n.callback)!=null&&r,a=n.maxWait,c=Date.now(),u=[];function f(){if(a!==void 0){var v=Date.now()-c;if(v+t>=a)return a-v}return t}var p=function(){var v=[].slice.call(arguments),y=this;return new Promise(function(w,x){var g=i&&s===void 0;if(s!==void 0&&clearTimeout(s),s=setTimeout(function(){if(s=void 0,c=Date.now(),!i){var I=e.apply(y,v);l&&l(I),u.forEach(function(k){return(0,k.resolve)(I)}),u=[]}},f()),g){var b=e.apply(y,v);return l&&l(b),w(b)}u.push({resolve:w,reject:x})})};return p.cancel=function(v){s!==void 0&&clearTimeout(s),u.forEach(function(y){return(0,y.reject)(v)}),u=[]},p}/*! + * vue-router v4.2.4 + * (c) 2023 Eduardo San Martin Morote + * @license MIT + */const nn=typeof window<"u";function mf(e){return e.__esModule||e[Symbol.toStringTag]==="Module"}const ve=Object.assign;function Zo(e,t){const n={};for(const o in t){const r=t[o];n[o]=it(r)?r.map(e):e(r)}return n}const In=()=>{},it=Array.isArray,vf=/\/$/,gf=e=>e.replace(vf,"");function Xo(e,t,n="/"){let o,r={},s="",i="";const l=t.indexOf("#");let a=t.indexOf("?");return l=0&&(a=-1),a>-1&&(o=t.slice(0,a),s=t.slice(a+1,l>-1?l:t.length),r=e(s)),l>-1&&(o=o||t.slice(0,l),i=t.slice(l,t.length)),o=Ef(o??t,n),{fullPath:o+(s&&"?")+s+i,path:o,query:r,hash:i}}function _f(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Us(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function bf(e,t,n){const o=t.matched.length-1,r=n.matched.length-1;return o>-1&&o===r&&hn(t.matched[o],n.matched[r])&&Bl(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function hn(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Bl(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!yf(e[n],t[n]))return!1;return!0}function yf(e,t){return it(e)?Ks(e,t):it(t)?Ks(t,e):e===t}function Ks(e,t){return it(t)?e.length===t.length&&e.every((n,o)=>n===t[o]):e.length===1&&e[0]===t}function Ef(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),o=e.split("/"),r=o[o.length-1];(r===".."||r===".")&&o.push("");let s=n.length-1,i,l;for(i=0;i1&&s--;else break;return n.slice(0,s).join("/")+"/"+o.slice(i-(i===o.length?1:0)).join("/")}var jn;(function(e){e.pop="pop",e.push="push"})(jn||(jn={}));var An;(function(e){e.back="back",e.forward="forward",e.unknown=""})(An||(An={}));function wf(e){if(!e)if(nn){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),gf(e)}const Cf=/^[^#]+#/;function Tf(e,t){return e.replace(Cf,"#")+t}function Lf(e,t){const n=document.documentElement.getBoundingClientRect(),o=e.getBoundingClientRect();return{behavior:t.behavior,left:o.left-n.left-(t.left||0),top:o.top-n.top-(t.top||0)}}const Fo=()=>({left:window.pageXOffset,top:window.pageYOffset});function xf(e){let t;if("el"in e){const n=e.el,o=typeof n=="string"&&n.startsWith("#"),r=typeof n=="string"?o?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;t=Lf(r,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.pageXOffset,t.top!=null?t.top:window.pageYOffset)}function qs(e,t){return(history.state?history.state.position-t:-1)+e}const vr=new Map;function Pf(e,t){vr.set(e,t)}function Of(e){const t=vr.get(e);return vr.delete(e),t}let Sf=()=>location.protocol+"//"+location.host;function jl(e,t){const{pathname:n,search:o,hash:r}=t,s=e.indexOf("#");if(s>-1){let l=r.includes(e.slice(s))?e.slice(s).length:1,a=r.slice(l);return a[0]!=="/"&&(a="/"+a),Us(a,"")}return Us(n,e)+o+r}function kf(e,t,n,o){let r=[],s=[],i=null;const l=({state:p})=>{const v=jl(e,location),y=n.value,w=t.value;let x=0;if(p){if(n.value=v,t.value=p,i&&i===y){i=null;return}x=w?p.position-w.position:0}else o(v);r.forEach(g=>{g(n.value,y,{delta:x,type:jn.pop,direction:x?x>0?An.forward:An.back:An.unknown})})};function a(){i=n.value}function c(p){r.push(p);const v=()=>{const y=r.indexOf(p);y>-1&&r.splice(y,1)};return s.push(v),v}function u(){const{history:p}=window;p.state&&p.replaceState(ve({},p.state,{scroll:Fo()}),"")}function f(){for(const p of s)p();s=[],window.removeEventListener("popstate",l),window.removeEventListener("beforeunload",u)}return window.addEventListener("popstate",l),window.addEventListener("beforeunload",u,{passive:!0}),{pauseListeners:a,listen:c,destroy:f}}function Ws(e,t,n,o=!1,r=!1){return{back:e,current:t,forward:n,replaced:o,position:window.history.length,scroll:r?Fo():null}}function If(e){const{history:t,location:n}=window,o={value:jl(e,n)},r={value:t.state};r.value||s(o.value,{back:null,current:o.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function s(a,c,u){const f=e.indexOf("#"),p=f>-1?(n.host&&document.querySelector("base")?e:e.slice(f))+a:Sf()+e+a;try{t[u?"replaceState":"pushState"](c,"",p),r.value=c}catch(v){console.error(v),n[u?"replace":"assign"](p)}}function i(a,c){const u=ve({},t.state,Ws(r.value.back,a,r.value.forward,!0),c,{position:r.value.position});s(a,u,!0),o.value=a}function l(a,c){const u=ve({},r.value,t.state,{forward:a,scroll:Fo()});s(u.current,u,!0);const f=ve({},Ws(o.value,a,null),{position:u.position+1},c);s(a,f,!1),o.value=a}return{location:o,state:r,push:l,replace:i}}function Af(e){e=wf(e);const t=If(e),n=kf(e,t.state,t.location,t.replace);function o(s,i=!0){i||n.pauseListeners(),history.go(s)}const r=ve({location:"",base:e,go:o,createHref:Tf.bind(null,e)},t,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>t.state.value}),r}function Rf(e){return typeof e=="string"||e&&typeof e=="object"}function Vl(e){return typeof e=="string"||typeof e=="symbol"}const ht={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0},Fl=Symbol("");var Js;(function(e){e[e.aborted=4]="aborted",e[e.cancelled=8]="cancelled",e[e.duplicated=16]="duplicated"})(Js||(Js={}));function mn(e,t){return ve(new Error,{type:e,[Fl]:!0},t)}function dt(e,t){return e instanceof Error&&Fl in e&&(t==null||!!(e.type&t))}const Qs="[^/]+?",Df={sensitive:!1,strict:!1,start:!0,end:!0},$f=/[.+*?^${}()[\]/\\]/g;function Mf(e,t){const n=ve({},Df,t),o=[];let r=n.start?"^":"";const s=[];for(const c of e){const u=c.length?[]:[90];n.strict&&!c.length&&(r+="/");for(let f=0;ft.length?t.length===1&&t[0]===40+40?1:-1:0}function Hf(e,t){let n=0;const o=e.score,r=t.score;for(;n0&&t[t.length-1]<0}const Bf={type:0,value:""},jf=/[a-zA-Z0-9_]/;function Vf(e){if(!e)return[[]];if(e==="/")return[[Bf]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(v){throw new Error(`ERR (${n})/"${c}": ${v}`)}let n=0,o=n;const r=[];let s;function i(){s&&r.push(s),s=[]}let l=0,a,c="",u="";function f(){c&&(n===0?s.push({type:0,value:c}):n===1||n===2||n===3?(s.length>1&&(a==="*"||a==="+")&&t(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),s.push({type:1,value:c,regexp:u,repeatable:a==="*"||a==="+",optional:a==="*"||a==="?"})):t("Invalid state to consume buffer"),c="")}function p(){c+=a}for(;l{i(b)}:In}function i(u){if(Vl(u)){const f=o.get(u);f&&(o.delete(u),n.splice(n.indexOf(f),1),f.children.forEach(i),f.alias.forEach(i))}else{const f=n.indexOf(u);f>-1&&(n.splice(f,1),u.record.name&&o.delete(u.record.name),u.children.forEach(i),u.alias.forEach(i))}}function l(){return n}function a(u){let f=0;for(;f=0&&(u.record.path!==n[f].record.path||!zl(u,n[f]));)f++;n.splice(f,0,u),u.record.name&&!Zs(u)&&o.set(u.record.name,u)}function c(u,f){let p,v={},y,w;if("name"in u&&u.name){if(p=o.get(u.name),!p)throw mn(1,{location:u});w=p.record.name,v=ve(Gs(f.params,p.keys.filter(b=>!b.optional).map(b=>b.name)),u.params&&Gs(u.params,p.keys.map(b=>b.name))),y=p.stringify(v)}else if("path"in u)y=u.path,p=n.find(b=>b.re.test(y)),p&&(v=p.parse(y),w=p.record.name);else{if(p=f.name?o.get(f.name):n.find(b=>b.re.test(f.path)),!p)throw mn(1,{location:u,currentLocation:f});w=p.record.name,v=ve({},f.params,u.params),y=p.stringify(v)}const x=[];let g=p;for(;g;)x.unshift(g.record),g=g.parent;return{name:w,path:y,params:v,matched:x,meta:qf(x)}}return e.forEach(u=>s(u)),{addRoute:s,resolve:c,removeRoute:i,getRoutes:l,getRecordMatcher:r}}function Gs(e,t){const n={};for(const o of t)o in e&&(n[o]=e[o]);return n}function Uf(e){return{path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:void 0,beforeEnter:e.beforeEnter,props:Kf(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}}}function Kf(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const o in e.components)t[o]=typeof n=="object"?n[o]:n;return t}function Zs(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function qf(e){return e.reduce((t,n)=>ve(t,n.meta),{})}function Xs(e,t){const n={};for(const o in e)n[o]=o in t?t[o]:e[o];return n}function zl(e,t){return t.children.some(n=>n===e||zl(e,n))}const Ul=/#/g,Wf=/&/g,Jf=/\//g,Qf=/=/g,Yf=/\?/g,Kl=/\+/g,Gf=/%5B/g,Zf=/%5D/g,ql=/%5E/g,Xf=/%60/g,Wl=/%7B/g,ed=/%7C/g,Jl=/%7D/g,td=/%20/g;function Wr(e){return encodeURI(""+e).replace(ed,"|").replace(Gf,"[").replace(Zf,"]")}function nd(e){return Wr(e).replace(Wl,"{").replace(Jl,"}").replace(ql,"^")}function gr(e){return Wr(e).replace(Kl,"%2B").replace(td,"+").replace(Ul,"%23").replace(Wf,"%26").replace(Xf,"`").replace(Wl,"{").replace(Jl,"}").replace(ql,"^")}function od(e){return gr(e).replace(Qf,"%3D")}function rd(e){return Wr(e).replace(Ul,"%23").replace(Yf,"%3F")}function sd(e){return e==null?"":rd(e).replace(Jf,"%2F")}function xo(e){try{return decodeURIComponent(""+e)}catch{}return""+e}function id(e){const t={};if(e===""||e==="?")return t;const o=(e[0]==="?"?e.slice(1):e).split("&");for(let r=0;rs&&gr(s)):[o&&gr(o)]).forEach(s=>{s!==void 0&&(t+=(t.length?"&":"")+n,s!=null&&(t+="="+s))})}return t}function ld(e){const t={};for(const n in e){const o=e[n];o!==void 0&&(t[n]=it(o)?o.map(r=>r==null?null:""+r):o==null?o:""+o)}return t}const ad=Symbol(""),ti=Symbol(""),zo=Symbol(""),Jr=Symbol(""),_r=Symbol("");function wn(){let e=[];function t(o){return e.push(o),()=>{const r=e.indexOf(o);r>-1&&e.splice(r,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function At(e,t,n,o,r){const s=o&&(o.enterCallbacks[r]=o.enterCallbacks[r]||[]);return()=>new Promise((i,l)=>{const a=f=>{f===!1?l(mn(4,{from:n,to:t})):f instanceof Error?l(f):Rf(f)?l(mn(2,{from:t,to:f})):(s&&o.enterCallbacks[r]===s&&typeof f=="function"&&s.push(f),i())},c=e.call(o&&o.instances[r],t,n,a);let u=Promise.resolve(c);e.length<3&&(u=u.then(a)),u.catch(f=>l(f))})}function er(e,t,n,o){const r=[];for(const s of e)for(const i in s.components){let l=s.components[i];if(!(t!=="beforeRouteEnter"&&!s.instances[i]))if(cd(l)){const c=(l.__vccOpts||l)[t];c&&r.push(At(c,n,o,s,i))}else{let a=l();r.push(()=>a.then(c=>{if(!c)return Promise.reject(new Error(`Couldn't resolve component "${i}" at "${s.path}"`));const u=mf(c)?c.default:c;s.components[i]=u;const p=(u.__vccOpts||u)[t];return p&&At(p,n,o,s,i)()}))}}return r}function cd(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function ni(e){const t=Oe(zo),n=Oe(Jr),o=z(()=>t.resolve(X(e.to))),r=z(()=>{const{matched:a}=o.value,{length:c}=a,u=a[c-1],f=n.matched;if(!u||!f.length)return-1;const p=f.findIndex(hn.bind(null,u));if(p>-1)return p;const v=oi(a[c-2]);return c>1&&oi(u)===v&&f[f.length-1].path!==v?f.findIndex(hn.bind(null,a[c-2])):p}),s=z(()=>r.value>-1&&pd(n.params,o.value.params)),i=z(()=>r.value>-1&&r.value===n.matched.length-1&&Bl(n.params,o.value.params));function l(a={}){return dd(a)?t[X(e.replace)?"replace":"push"](X(e.to)).catch(In):Promise.resolve()}return{route:o,href:z(()=>o.value.href),isActive:s,isExactActive:i,navigate:l}}const ud=fe({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"}},useLink:ni,setup(e,{slots:t}){const n=Kn(ni(e)),{options:o}=Oe(zo),r=z(()=>({[ri(e.activeClass,o.linkActiveClass,"router-link-active")]:n.isActive,[ri(e.exactActiveClass,o.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const s=t.default&&t.default(n);return e.custom?s:ge("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:r.value},s)}}}),fd=ud;function dd(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function pd(e,t){for(const n in t){const o=t[n],r=e[n];if(typeof o=="string"){if(o!==r)return!1}else if(!it(r)||r.length!==o.length||o.some((s,i)=>s!==r[i]))return!1}return!0}function oi(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const ri=(e,t,n)=>e??t??n,hd=fe({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const o=Oe(_r),r=z(()=>e.route||o.value),s=Oe(ti,0),i=z(()=>{let c=X(s);const{matched:u}=r.value;let f;for(;(f=u[c])&&!f.components;)c++;return c}),l=z(()=>r.value.matched[i.value]);Wt(ti,z(()=>i.value+1)),Wt(ad,l),Wt(_r,r);const a=be();return et(()=>[a.value,l.value,e.name],([c,u,f],[p,v,y])=>{u&&(u.instances[f]=c,v&&v!==u&&c&&c===p&&(u.leaveGuards.size||(u.leaveGuards=v.leaveGuards),u.updateGuards.size||(u.updateGuards=v.updateGuards))),c&&u&&(!v||!hn(u,v)||!p)&&(u.enterCallbacks[f]||[]).forEach(w=>w(c))},{flush:"post"}),()=>{const c=r.value,u=e.name,f=l.value,p=f&&f.components[u];if(!p)return si(n.default,{Component:p,route:c});const v=f.props[u],y=v?v===!0?c.params:typeof v=="function"?v(c):v:null,x=ge(p,ve({},y,t,{onVnodeUnmounted:g=>{g.component.isUnmounted&&(f.instances[u]=null)},ref:a}));return si(n.default,{Component:x,route:c})||x}}});function si(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const Ql=hd;function md(e){const t=zf(e.routes,e),n=e.parseQuery||id,o=e.stringifyQuery||ei,r=e.history,s=wn(),i=wn(),l=wn(),a=Rr(ht);let c=ht;nn&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const u=Zo.bind(null,C=>""+C),f=Zo.bind(null,sd),p=Zo.bind(null,xo);function v(C,V){let $,J;return Vl(C)?($=t.getRecordMatcher(C),J=V):J=C,t.addRoute(J,$)}function y(C){const V=t.getRecordMatcher(C);V&&t.removeRoute(V)}function w(){return t.getRoutes().map(C=>C.record)}function x(C){return!!t.getRecordMatcher(C)}function g(C,V){if(V=ve({},V||a.value),typeof C=="string"){const _=Xo(n,C,V.path),E=t.resolve({path:_.path},V),T=r.createHref(_.fullPath);return ve(_,E,{params:p(E.params),hash:xo(_.hash),redirectedFrom:void 0,href:T})}let $;if("path"in C)$=ve({},C,{path:Xo(n,C.path,V.path).path});else{const _=ve({},C.params);for(const E in _)_[E]==null&&delete _[E];$=ve({},C,{params:f(_)}),V.params=f(V.params)}const J=t.resolve($,V),ue=C.hash||"";J.params=u(p(J.params));const d=_f(o,ve({},C,{hash:nd(ue),path:J.path})),h=r.createHref(d);return ve({fullPath:d,hash:ue,query:o===ei?ld(C.query):C.query||{}},J,{redirectedFrom:void 0,href:h})}function b(C){return typeof C=="string"?Xo(n,C,a.value.path):ve({},C)}function I(C,V){if(c!==C)return mn(8,{from:V,to:C})}function k(C){return N(C)}function W(C){return k(ve(b(C),{replace:!0}))}function ee(C){const V=C.matched[C.matched.length-1];if(V&&V.redirect){const{redirect:$}=V;let J=typeof $=="function"?$(C):$;return typeof J=="string"&&(J=J.includes("?")||J.includes("#")?J=b(J):{path:J},J.params={}),ve({query:C.query,hash:C.hash,params:"path"in J?{}:C.params},J)}}function N(C,V){const $=c=g(C),J=a.value,ue=C.state,d=C.force,h=C.replace===!0,_=ee($);if(_)return N(ve(b(_),{state:typeof _=="object"?ve({},ue,_.state):ue,force:d,replace:h}),V||$);const E=$;E.redirectedFrom=V;let T;return!d&&bf(o,J,$)&&(T=mn(16,{to:E,from:J}),Ue(J,J,!0,!1)),(T?Promise.resolve(T):B(E,J)).catch(P=>dt(P)?dt(P,2)?P:$e(P):ie(P,E,J)).then(P=>{if(P){if(dt(P,2))return N(ve({replace:h},b(P.to),{state:typeof P.to=="object"?ve({},ue,P.to.state):ue,force:d}),V||E)}else P=L(E,J,!0,h,ue);return Y(E,J,P),P})}function m(C,V){const $=I(C,V);return $?Promise.reject($):Promise.resolve()}function F(C){const V=Lt.values().next().value;return V&&typeof V.runWithContext=="function"?V.runWithContext(C):C()}function B(C,V){let $;const[J,ue,d]=vd(C,V);$=er(J.reverse(),"beforeRouteLeave",C,V);for(const _ of J)_.leaveGuards.forEach(E=>{$.push(At(E,C,V))});const h=m.bind(null,C,V);return $.push(h),De($).then(()=>{$=[];for(const _ of s.list())$.push(At(_,C,V));return $.push(h),De($)}).then(()=>{$=er(ue,"beforeRouteUpdate",C,V);for(const _ of ue)_.updateGuards.forEach(E=>{$.push(At(E,C,V))});return $.push(h),De($)}).then(()=>{$=[];for(const _ of d)if(_.beforeEnter)if(it(_.beforeEnter))for(const E of _.beforeEnter)$.push(At(E,C,V));else $.push(At(_.beforeEnter,C,V));return $.push(h),De($)}).then(()=>(C.matched.forEach(_=>_.enterCallbacks={}),$=er(d,"beforeRouteEnter",C,V),$.push(h),De($))).then(()=>{$=[];for(const _ of i.list())$.push(At(_,C,V));return $.push(h),De($)}).catch(_=>dt(_,8)?_:Promise.reject(_))}function Y(C,V,$){l.list().forEach(J=>F(()=>J(C,V,$)))}function L(C,V,$,J,ue){const d=I(C,V);if(d)return d;const h=V===ht,_=nn?history.state:{};$&&(J||h?r.replace(C.fullPath,ve({scroll:h&&_&&_.scroll},ue)):r.push(C.fullPath,ue)),a.value=C,Ue(C,V,$,h),$e()}let R;function D(){R||(R=r.listen((C,V,$)=>{if(!lt.listening)return;const J=g(C),ue=ee(J);if(ue){N(ve(ue,{replace:!0}),J).catch(In);return}c=J;const d=a.value;nn&&Pf(qs(d.fullPath,$.delta),Fo()),B(J,d).catch(h=>dt(h,12)?h:dt(h,2)?(N(h.to,J).then(_=>{dt(_,20)&&!$.delta&&$.type===jn.pop&&r.go(-1,!1)}).catch(In),Promise.reject()):($.delta&&r.go(-$.delta,!1),ie(h,J,d))).then(h=>{h=h||L(J,d,!1),h&&($.delta&&!dt(h,8)?r.go(-$.delta,!1):$.type===jn.pop&&dt(h,20)&&r.go(-1,!1)),Y(J,d,h)}).catch(In)}))}let le=wn(),U=wn(),se;function ie(C,V,$){$e(C);const J=U.list();return J.length?J.forEach(ue=>ue(C,V,$)):console.error(C),Promise.reject(C)}function He(){return se&&a.value!==ht?Promise.resolve():new Promise((C,V)=>{le.add([C,V])})}function $e(C){return se||(se=!C,D(),le.list().forEach(([V,$])=>C?$(C):V()),le.reset()),C}function Ue(C,V,$,J){const{scrollBehavior:ue}=e;if(!nn||!ue)return Promise.resolve();const d=!$&&Of(qs(C.fullPath,0))||(J||!$)&&history.state&&history.state.scroll||null;return Do().then(()=>ue(C,V,d)).then(h=>h&&xf(h)).catch(h=>ie(h,C,V))}const Be=C=>r.go(C);let Tt;const Lt=new Set,lt={currentRoute:a,listening:!0,addRoute:v,removeRoute:y,hasRoute:x,getRoutes:w,resolve:g,options:e,push:k,replace:W,go:Be,back:()=>Be(-1),forward:()=>Be(1),beforeEach:s.add,beforeResolve:i.add,afterEach:l.add,onError:U.add,isReady:He,install(C){const V=this;C.component("RouterLink",fd),C.component("RouterView",Ql),C.config.globalProperties.$router=V,Object.defineProperty(C.config.globalProperties,"$route",{enumerable:!0,get:()=>X(a)}),nn&&!Tt&&a.value===ht&&(Tt=!0,k(r.location).catch(ue=>{}));const $={};for(const ue in ht)Object.defineProperty($,ue,{get:()=>a.value[ue],enumerable:!0});C.provide(zo,V),C.provide(Jr,zi($)),C.provide(_r,a);const J=C.unmount;Lt.add(C),C.unmount=function(){Lt.delete(C),Lt.size<1&&(c=ht,R&&R(),R=null,a.value=ht,Tt=!1,se=!1),J()}}};function De(C){return C.reduce((V,$)=>V.then(()=>F($)),Promise.resolve())}return lt}function vd(e,t){const n=[],o=[],r=[],s=Math.max(t.matched.length,e.matched.length);for(let i=0;ihn(c,l))?o.push(l):n.push(l));const a=e.matched[i];a&&(t.matched.find(c=>hn(c,a))||r.push(a))}return[n,o,r]}function Gt(){return Oe(zo)}function Zt(){return Oe(Jr)}const gd=({headerLinkSelector:e,headerAnchorSelector:t,delay:n,offset:o=5})=>{const r=Gt(),i=qr(()=>{var w,x;const l=Math.max(window.scrollY,document.documentElement.scrollTop,document.body.scrollTop);if(Math.abs(l-0)p.some(b=>b.hash===g.hash));for(let g=0;g=(((w=b.parentElement)==null?void 0:w.offsetTop)??0)-o,W=!I||l<(((x=I.parentElement)==null?void 0:x.offsetTop)??0)-o;if(!(k&&W))continue;const N=decodeURIComponent(r.currentRoute.value.hash),m=decodeURIComponent(b.hash);if(N===m)return;if(f){for(let F=g+1;F{window.addEventListener("scroll",i)}),Jn(()=>{window.removeEventListener("scroll",i)})},ii=async(e,t)=>{const{scrollBehavior:n}=e.options;e.options.scrollBehavior=void 0,await e.replace({query:e.currentRoute.value.query,hash:t}).finally(()=>e.options.scrollBehavior=n)},_d="a.sidebar-item",bd=".header-anchor",yd=300,Ed=5,wd=Ct({setup(){gd({headerLinkSelector:_d,headerAnchorSelector:bd,delay:yd,offset:Ed})}}),li=()=>window.pageYOffset||document.documentElement.scrollTop||document.body.scrollTop||0,Cd=()=>window.scrollTo({top:0,behavior:"smooth"});const Td=fe({name:"BackToTop",setup(){const e=be(0),t=z(()=>e.value>300),n=qr(()=>{e.value=li()},100);We(()=>{e.value=li(),window.addEventListener("scroll",()=>n())});const o=ge("div",{class:"back-to-top",onClick:Cd});return()=>ge(Qn,{name:"back-to-top"},()=>t.value?o:null)}}),Ld=Ct({rootComponents:[Td]});const xd=ge("svg",{class:"external-link-icon",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false",x:"0px",y:"0px",viewBox:"0 0 100 100",width:"15",height:"15"},[ge("path",{fill:"currentColor",d:"M18.8,85.1h56l0,0c2.2,0,4-1.8,4-4v-32h-8v28h-48v-48h28v-8h-32l0,0c-2.2,0-4,1.8-4,4v56C14.8,83.3,16.6,85.1,18.8,85.1z"}),ge("polygon",{fill:"currentColor",points:"45.7,48.7 51.3,54.3 77.2,28.5 77.2,37.2 85.2,37.2 85.2,14.9 62.8,14.9 62.8,22.9 71.5,22.9"})]),Pd=fe({name:"ExternalLinkIcon",props:{locales:{type:Object,required:!1,default:()=>({})}},setup(e){const t=Gn(),n=z(()=>e.locales[t.value]??{openInNewWindow:"open in new window"});return()=>ge("span",[xd,ge("span",{class:"external-link-icon-sr-only"},n.value.openInNewWindow)])}}),Od={"/":{openInNewWindow:"open in new window"}},Sd=Ct({enhance({app:e}){e.component("ExternalLinkIcon",ge(Pd,{locales:Od}))}});/*! medium-zoom 1.0.8 | MIT License | https://github.com/francoischalifour/medium-zoom */var Vt=Object.assign||function(e){for(var t=1;t1&&arguments[1]!==void 0?arguments[1]:{},o=window.Promise||function(L){function R(){}L(R,R)},r=function(L){var R=L.target;if(R===F){y();return}I.indexOf(R)!==-1&&w({target:R})},s=function(){if(!(W||!m.original)){var L=window.pageYOffset||document.documentElement.scrollTop||document.body.scrollTop||0;Math.abs(ee-L)>N.scrollOffset&&setTimeout(y,150)}},i=function(L){var R=L.key||L.keyCode;(R==="Escape"||R==="Esc"||R===27)&&y()},l=function(){var L=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},R=L;if(L.background&&(F.style.background=L.background),L.container&&L.container instanceof Object&&(R.container=Vt({},N.container,L.container)),L.template){var D=ho(L.template)?L.template:document.querySelector(L.template);R.template=D}return N=Vt({},N,R),I.forEach(function(le){le.dispatchEvent(tn("medium-zoom:update",{detail:{zoom:B}}))}),B},a=function(){var L=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};return e(Vt({},N,L))},c=function(){for(var L=arguments.length,R=Array(L),D=0;D0?R.reduce(function(U,se){return[].concat(U,ci(se))},[]):I;return le.forEach(function(U){U.classList.remove("medium-zoom-image"),U.dispatchEvent(tn("medium-zoom:detach",{detail:{zoom:B}}))}),I=I.filter(function(U){return le.indexOf(U)===-1}),B},f=function(L,R){var D=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};return I.forEach(function(le){le.addEventListener("medium-zoom:"+L,R,D)}),k.push({type:"medium-zoom:"+L,listener:R,options:D}),B},p=function(L,R){var D=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};return I.forEach(function(le){le.removeEventListener("medium-zoom:"+L,R,D)}),k=k.filter(function(le){return!(le.type==="medium-zoom:"+L&&le.listener.toString()===R.toString())}),B},v=function(){var L=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},R=L.target,D=function(){var U={width:document.documentElement.clientWidth,height:document.documentElement.clientHeight,left:0,top:0,right:0,bottom:0},se=void 0,ie=void 0;if(N.container)if(N.container instanceof Object)U=Vt({},U,N.container),se=U.width-U.left-U.right-N.margin*2,ie=U.height-U.top-U.bottom-N.margin*2;else{var He=ho(N.container)?N.container:document.querySelector(N.container),$e=He.getBoundingClientRect(),Ue=$e.width,Be=$e.height,Tt=$e.left,Lt=$e.top;U=Vt({},U,{width:Ue,height:Be,left:Tt,top:Lt})}se=se||U.width-N.margin*2,ie=ie||U.height-N.margin*2;var lt=m.zoomedHd||m.original,De=ai(lt)?se:lt.naturalWidth||se,C=ai(lt)?ie:lt.naturalHeight||ie,V=lt.getBoundingClientRect(),$=V.top,J=V.left,ue=V.width,d=V.height,h=Math.min(Math.max(ue,De),se)/ue,_=Math.min(Math.max(d,C),ie)/d,E=Math.min(h,_),T=(-J+(se-ue)/2+N.margin+U.left)/E,P=(-$+(ie-d)/2+N.margin+U.top)/E,j="scale("+E+") translate3d("+T+"px, "+P+"px, 0)";m.zoomed.style.transform=j,m.zoomedHd&&(m.zoomedHd.style.transform=j)};return new o(function(le){if(R&&I.indexOf(R)===-1){le(B);return}var U=function Ue(){W=!1,m.zoomed.removeEventListener("transitionend",Ue),m.original.dispatchEvent(tn("medium-zoom:opened",{detail:{zoom:B}})),le(B)};if(m.zoomed){le(B);return}if(R)m.original=R;else if(I.length>0){var se=I;m.original=se[0]}else{le(B);return}if(m.original.dispatchEvent(tn("medium-zoom:open",{detail:{zoom:B}})),ee=window.pageYOffset||document.documentElement.scrollTop||document.body.scrollTop||0,W=!0,m.zoomed=Ad(m.original),document.body.appendChild(F),N.template){var ie=ho(N.template)?N.template:document.querySelector(N.template);m.template=document.createElement("div"),m.template.appendChild(ie.content.cloneNode(!0)),document.body.appendChild(m.template)}if(m.original.parentElement&&m.original.parentElement.tagName==="PICTURE"&&m.original.currentSrc&&(m.zoomed.src=m.original.currentSrc),document.body.appendChild(m.zoomed),window.requestAnimationFrame(function(){document.body.classList.add("medium-zoom--opened")}),m.original.classList.add("medium-zoom-image--hidden"),m.zoomed.classList.add("medium-zoom-image--opened"),m.zoomed.addEventListener("click",y),m.zoomed.addEventListener("transitionend",U),m.original.getAttribute("data-zoom-src")){m.zoomedHd=m.zoomed.cloneNode(),m.zoomedHd.removeAttribute("srcset"),m.zoomedHd.removeAttribute("sizes"),m.zoomedHd.removeAttribute("loading"),m.zoomedHd.src=m.zoomed.getAttribute("data-zoom-src"),m.zoomedHd.onerror=function(){clearInterval(He),console.warn("Unable to reach the zoom image target "+m.zoomedHd.src),m.zoomedHd=null,D()};var He=setInterval(function(){m.zoomedHd.complete&&(clearInterval(He),m.zoomedHd.classList.add("medium-zoom-image--opened"),m.zoomedHd.addEventListener("click",y),document.body.appendChild(m.zoomedHd),D())},10)}else if(m.original.hasAttribute("srcset")){m.zoomedHd=m.zoomed.cloneNode(),m.zoomedHd.removeAttribute("sizes"),m.zoomedHd.removeAttribute("loading");var $e=m.zoomedHd.addEventListener("load",function(){m.zoomedHd.removeEventListener("load",$e),m.zoomedHd.classList.add("medium-zoom-image--opened"),m.zoomedHd.addEventListener("click",y),document.body.appendChild(m.zoomedHd),D()})}else D()})},y=function(){return new o(function(L){if(W||!m.original){L(B);return}var R=function D(){m.original.classList.remove("medium-zoom-image--hidden"),document.body.removeChild(m.zoomed),m.zoomedHd&&document.body.removeChild(m.zoomedHd),document.body.removeChild(F),m.zoomed.classList.remove("medium-zoom-image--opened"),m.template&&document.body.removeChild(m.template),W=!1,m.zoomed.removeEventListener("transitionend",D),m.original.dispatchEvent(tn("medium-zoom:closed",{detail:{zoom:B}})),m.original=null,m.zoomed=null,m.zoomedHd=null,m.template=null,L(B)};W=!0,document.body.classList.remove("medium-zoom--opened"),m.zoomed.style.transform="",m.zoomedHd&&(m.zoomedHd.style.transform=""),m.template&&(m.template.style.transition="opacity 150ms",m.template.style.opacity=0),m.original.dispatchEvent(tn("medium-zoom:close",{detail:{zoom:B}})),m.zoomed.addEventListener("transitionend",R)})},w=function(){var L=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},R=L.target;return m.original?y():v({target:R})},x=function(){return N},g=function(){return I},b=function(){return m.original},I=[],k=[],W=!1,ee=0,N=n,m={original:null,zoomed:null,zoomedHd:null,template:null};Object.prototype.toString.call(t)==="[object Object]"?N=t:(t||typeof t=="string")&&c(t),N=Vt({margin:0,background:"#fff",scrollOffset:40,container:null,template:null},N);var F=Id(N.background);document.addEventListener("click",r),document.addEventListener("keyup",i),document.addEventListener("scroll",s),window.addEventListener("resize",y);var B={open:v,close:y,toggle:w,update:l,clone:a,attach:c,detach:u,on:f,off:p,getOptions:x,getImages:g,getZoomedImage:b};return B};function Dd(e,t){t===void 0&&(t={});var n=t.insertAt;if(!(!e||typeof document>"u")){var o=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css",n==="top"&&o.firstChild?o.insertBefore(r,o.firstChild):o.appendChild(r),r.styleSheet?r.styleSheet.cssText=e:r.appendChild(document.createTextNode(e))}}var $d=".medium-zoom-overlay{position:fixed;top:0;right:0;bottom:0;left:0;opacity:0;transition:opacity .3s;will-change:opacity}.medium-zoom--opened .medium-zoom-overlay{cursor:pointer;cursor:zoom-out;opacity:1}.medium-zoom-image{cursor:pointer;cursor:zoom-in;transition:transform .3s cubic-bezier(.2,0,.2,1)!important}.medium-zoom-image--hidden{visibility:hidden}.medium-zoom-image--opened{position:relative;cursor:pointer;cursor:zoom-out;will-change:transform}";Dd($d);const Md=Rd,Nd=Symbol("mediumZoom");const Hd=".theme-default-content > img, .theme-default-content :not(a) > img",Bd={},jd=300,Vd=Ct({enhance({app:e,router:t}){const n=Md(Bd);n.refresh=(o=Hd)=>{n.detach(),n.attach(o)},e.provide(Nd,n),t.afterEach(()=>{setTimeout(()=>n.refresh(),jd)})}});/** + * NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress + * @license MIT + */const de={settings:{minimum:.08,easing:"ease",speed:200,trickle:!0,trickleRate:.02,trickleSpeed:800,barSelector:'[role="bar"]',parent:"body",template:'
'},status:null,set:e=>{const t=de.isStarted();e=tr(e,de.settings.minimum,1),de.status=e===1?null:e;const n=de.render(!t),o=n.querySelector(de.settings.barSelector),r=de.settings.speed,s=de.settings.easing;return n.offsetWidth,Fd(i=>{lo(o,{transform:"translate3d("+ui(e)+"%,0,0)",transition:"all "+r+"ms "+s}),e===1?(lo(n,{transition:"none",opacity:"1"}),n.offsetWidth,setTimeout(function(){lo(n,{transition:"all "+r+"ms linear",opacity:"0"}),setTimeout(function(){de.remove(),i()},r)},r)):setTimeout(()=>i(),r)}),de},isStarted:()=>typeof de.status=="number",start:()=>{de.status||de.set(0);const e=()=>{setTimeout(()=>{de.status&&(de.trickle(),e())},de.settings.trickleSpeed)};return de.settings.trickle&&e(),de},done:e=>!e&&!de.status?de:de.inc(.3+.5*Math.random()).set(1),inc:e=>{let t=de.status;return t?(typeof e!="number"&&(e=(1-t)*tr(Math.random()*t,.1,.95)),t=tr(t+e,0,.994),de.set(t)):de.start()},trickle:()=>de.inc(Math.random()*de.settings.trickleRate),render:e=>{if(de.isRendered())return document.getElementById("nprogress");fi(document.documentElement,"nprogress-busy");const t=document.createElement("div");t.id="nprogress",t.innerHTML=de.settings.template;const n=t.querySelector(de.settings.barSelector),o=e?"-100":ui(de.status||0),r=document.querySelector(de.settings.parent);return lo(n,{transition:"all 0 linear",transform:"translate3d("+o+"%,0,0)"}),r!==document.body&&fi(r,"nprogress-custom-parent"),r==null||r.appendChild(t),t},remove:()=>{di(document.documentElement,"nprogress-busy"),di(document.querySelector(de.settings.parent),"nprogress-custom-parent");const e=document.getElementById("nprogress");e&&zd(e)},isRendered:()=>!!document.getElementById("nprogress")},tr=(e,t,n)=>en?n:e,ui=e=>(-1+e)*100,Fd=function(){const e=[];function t(){const n=e.shift();n&&n(t)}return function(n){e.push(n),e.length===1&&t()}}(),lo=function(){const e=["Webkit","O","Moz","ms"],t={};function n(i){return i.replace(/^-ms-/,"ms-").replace(/-([\da-z])/gi,function(l,a){return a.toUpperCase()})}function o(i){const l=document.body.style;if(i in l)return i;let a=e.length;const c=i.charAt(0).toUpperCase()+i.slice(1);let u;for(;a--;)if(u=e[a]+c,u in l)return u;return i}function r(i){return i=n(i),t[i]??(t[i]=o(i))}function s(i,l,a){l=r(l),i.style[l]=a}return function(i,l){for(const a in l){const c=l[a];c!==void 0&&Object.prototype.hasOwnProperty.call(l,a)&&s(i,a,c)}}}(),Yl=(e,t)=>(typeof e=="string"?e:Qr(e)).indexOf(" "+t+" ")>=0,fi=(e,t)=>{const n=Qr(e),o=n+t;Yl(n,t)||(e.className=o.substring(1))},di=(e,t)=>{const n=Qr(e);if(!Yl(e,t))return;const o=n.replace(" "+t+" "," ");e.className=o.substring(1,o.length-1)},Qr=e=>(" "+(e.className||"")+" ").replace(/\s+/gi," "),zd=e=>{e&&e.parentNode&&e.parentNode.removeChild(e)};const Ud=()=>{We(()=>{const e=Gt(),t=new Set;t.add(e.currentRoute.value.path),e.beforeEach(n=>{t.has(n.path)||de.start()}),e.afterEach(n=>{t.add(n.path),de.done()})})},Kd=Ct({setup(){Ud()}}),qd=JSON.parse(`{"repo":"https://github.com/bcgov/notifybc","packageJson":{"name":"notify-bc","version":"5.1.2","dbSchemaVersion":"0.9.0","description":"A versatile notification API server","author":"f-w","private":true,"main":"dist/main.js","types":"dist/main.d.ts","engines":{"node":">=18"},"repository":{"type":"git","url":"https://github.com/bcgov/notifybc"},"license":"Apache-2.0","scripts":{"build":"nest build","build:client":"cd client && npm run build","build:docs":"cd docs && npm i && npm run build","postbuild":"npm run build:client","install:client":"cd client && npm i","install:docs":"cd docs && npm i","postinstall":"npm run install:client","format":"prettier --write \\"src/**/*.ts\\" \\"test/**/*.ts\\"","start":"nest start","start:dev":"nest start --watch","start:debug":"nest start --debug --watch","start:prod":"node dist/main","lint":"eslint \\"{src,apps,libs,test}/**/*.ts\\" --fix","test":"jest","test:watch":"jest --watch","test:cov":"jest --coverage","test:debug":"node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand","test:e2e":"jest --config ./test/jest-e2e.ts","test:e2e:cov":"jest --config ./test/jest-e2e.ts --coverage"},"dependencies":{"@nestjs/common":"^10.3.8","@nestjs/core":"^10.3.8","@nestjs/mongoose":"^10.0.6","@nestjs/platform-express":"^10.3.8","@nestjs/swagger":"^7.3.1","@nestjs/terminus":"^10.2.3","async":"^3.2.4","axios":"^1.6.8","bcryptjs":"^2.4.3","bottleneck":"^2.19.5","class-transformer":"^0.5.1","class-validator":"^0.14.0","compression":"^1.7.4","cron":"^3.1.5","crypto-random-string":"^3.3.0","ejs":"^3.1.9","feedparser":"^2.2.10","helmet":"^7.0.0","ioredis":"^5.3.2","ip-range-check":"^0.2.0","jmespath":"f-w/jmespath.js#semver:^1.0","js-base64":"^3.7.5","jsonwebtoken":"^9.0.2","lodash":"^4.17.21","mailparser":"^3.6.5","mongodb-memory-server":"^9.2.0","mongoose":"^7.4.5","morgan":"^1.10.0","nodemailer":"^6.9.5","nodemailer-direct-transport":"^3.3.2","pluralize":"^8.0.0","randexp":"^0.5.3","reflect-metadata":"^0.1.13","rxjs":"^7.8.1","semver":"^7.5.4","smtp-server":"^3.13.0","twilio":"^3.7.0","underscore.string":"^3.3.6"},"devDependencies":{"@nestjs/cli":"^10.3.2","@nestjs/schematics":"^10.1.1","@nestjs/testing":"^10.3.8","@types/bcryptjs":"^2.4.3","@types/express":"^4.17.17","@types/jest":"^29.5.2","@types/lodash":"^4.14.197","@types/node":"^20.3.1","@types/supertest":"^2.0.12","@typescript-eslint/eslint-plugin":"^5.59.11","@typescript-eslint/parser":"^5.59.11","commander":"^11.1.0","csvtojson":"^2.0.10","eslint":"^8.42.0","eslint-config-prettier":"^8.8.0","eslint-plugin-prettier":"^4.2.1","jest":"^29.7.0","prettier":"^2.8.8","source-map-support":"^0.5.21","supertest":"^6.3.3","ts-jest":"^29.1.0","ts-node":"^10.9.1","typescript":"^5.1.3"},"optionalDependencies":{"redis-memory-server":"^0.10.0"},"jest":{"moduleFileExtensions":["js","json","ts"],"rootDir":"src","testRegex":".*\\\\.spec\\\\.ts$","transform":{"^.+\\\\.(t|j)s$":"ts-jest"},"collectCoverageFrom":["**/*.(t|j)s"],"coverageDirectory":"../coverage","testEnvironment":"node"}},"logo":"/img/logo.svg","docsDir":"","editLink":false,"contributors":false,"lastUpdated":false,"navbar":[{"text":"Home","link":"/"},{"text":"Docs","link":"/docs/"},{"text":"Help","link":"/help/"}],"sidebarDepth":1,"sidebar":[{"text":"Getting Started","children":["/docs/","/docs/overview/","/docs/quickstart/","/docs/installation/","/docs/web-console/","/docs/what's-new/"]},{"text":"Configuration","children":["/docs/config-overview/","/docs/config-database/","/docs/config-adminIpList/","/docs/config-reverseProxyIpLists/","/docs/config-httpHost/","/docs/config-internalHttpHost/","/docs/config-email/","/docs/config-sms/","/docs/config-subscription/","/docs/config-notification/","/docs/config-nodeRoles/","/docs/config-cronJobs/","/docs/config-rsaKeys/","/docs/config-workerProcessCount/","/docs/config-middleware/","/docs/config-oidc/","/docs/config-certificates/"]},{"text":"API","collapsed":false,"children":["/docs/api-overview/","/docs/api-subscription/","/docs/api-notification/","/docs/api-config/","/docs/api-administrator/","/docs/api-bounce/"]},{"text":"Miscellaneous","children":["/docs/health-check/","/docs/memory-dump/","/docs/benchmarks/","/docs/bulk-import/","/docs/developer-notes/","/docs/upgrade/"]},{"text":"Meta","children":["/docs/conduct/","/docs/acknowledgments/"]}],"locales":{"/":{"selectLanguageName":"English"}},"colorMode":"auto","colorModeSwitch":true,"selectLanguageText":"Languages","selectLanguageAriaLabel":"Select language","editLinkText":"Edit this page","lastUpdatedText":"Last Updated","contributorsText":"Contributors","notFound":["There's nothing here.","How did we get here?","That's a Four-Oh-Four.","Looks like we've got some broken links."],"backToHome":"Take me home","openInNewWindow":"open in new window","toggleColorMode":"toggle color mode","toggleSidebar":"toggle sidebar"}`),Wd=be(qd),Gl=()=>Wd,Zl=Symbol(""),Jd=()=>{const e=Oe(Zl);if(!e)throw new Error("useThemeLocaleData() is called without provider.");return e},Qd=(e,t)=>{const{locales:n,...o}=e;return{...o,...n==null?void 0:n[t]}},Yd=Ct({enhance({app:e}){const t=Gl(),n=e._context.provides[Fr],o=z(()=>Qd(t.value,n.value));e.provide(Zl,o),Object.defineProperties(e.config.globalProperties,{$theme:{get(){return t.value}},$themeLocale:{get(){return o.value}}})}}),Gd=fe({__name:"Badge",props:{type:{type:String,required:!1,default:"tip"},text:{type:String,required:!1,default:""},vertical:{type:String,required:!1,default:void 0}},setup(e){return(t,n)=>(H(),Q("span",{class:ze(["badge",e.type]),style:Qt({verticalAlign:e.vertical})},[Ce(t.$slots,"default",{},()=>[Et(Ie(e.text),1)])],6))}}),Le=(e,t)=>{const n=e.__vccOpts||e;for(const[o,r]of t)n[o]=r;return n},Zd=Le(Gd,[["__file","Badge.vue"]]),Xd=fe({name:"CodeGroup",slots:Object,setup(e,{slots:t}){const n=be(-1),o=be([]),r=(l=n.value)=>{l{l>0?n.value=l-1:n.value=o.value.length-1,o.value[n.value].focus()},i=(l,a)=>{l.key===" "||l.key==="Enter"?(l.preventDefault(),n.value=a):l.key==="ArrowRight"?(l.preventDefault(),r(a)):l.key==="ArrowLeft"&&(l.preventDefault(),s(a))};return()=>{var a;const l=(((a=t.default)==null?void 0:a.call(t))||[]).filter(c=>c.type.name==="CodeGroupItem").map(c=>(c.props===null&&(c.props={}),c));return l.length===0?null:(n.value<0||n.value>l.length-1?(n.value=l.findIndex(c=>c.props.active===""||c.props.active===!0),n.value===-1&&(n.value=0)):l.forEach((c,u)=>{c.props.active=u===n.value}),ge("div",{class:"code-group"},[ge("div",{class:"code-group__nav"},ge("ul",{class:"code-group__ul"},l.map((c,u)=>{const f=u===n.value;return ge("li",{class:"code-group__li"},ge("button",{ref:p=>{p&&(o.value[u]=p)},class:{"code-group__nav-tab":!0,"code-group__nav-tab-active":f},ariaPressed:f,ariaExpanded:f,onClick:()=>n.value=u,onKeydown:p=>i(p,u)},c.props.title))}))),l]))}}}),ep=["aria-selected"],tp=fe({name:"CodeGroupItem"}),np=fe({...tp,props:{title:{type:String,required:!0},active:{type:Boolean,required:!1,default:!1}},setup(e){return(t,n)=>(H(),Q("div",{class:ze(["code-group-item",{"code-group-item__active":e.active}]),"aria-selected":e.active},[Ce(t.$slots,"default")],10,ep))}}),op=Le(np,[["__file","CodeGroupItem.vue"]]);var rp=Object.defineProperty,sp=Object.defineProperties,ip=Object.getOwnPropertyDescriptors,pi=Object.getOwnPropertySymbols,lp=Object.prototype.hasOwnProperty,ap=Object.prototype.propertyIsEnumerable,hi=(e,t,n)=>t in e?rp(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,cp=(e,t)=>{for(var n in t||(t={}))lp.call(t,n)&&hi(e,n,t[n]);if(pi)for(var n of pi(t))ap.call(t,n)&&hi(e,n,t[n]);return e},up=(e,t)=>sp(e,ip(t));function mi(e,t){var n;const o=Rr();return el(()=>{o.value=e()},up(cp({},t),{flush:(n=t==null?void 0:t.flush)!=null?n:"sync"})),_n(o)}function Xl(e){return Ai()?(Da(e),!0):!1}function Vn(e){return typeof e=="function"?e():X(e)}const fp=typeof window<"u",ea=()=>{};function dp(e,t){function n(...o){return new Promise((r,s)=>{Promise.resolve(e(()=>t.apply(this,o),{fn:t,thisArg:this,args:o})).then(r).catch(s)})}return n}const ta=e=>e();function pp(e=ta){const t=be(!0);function n(){t.value=!1}function o(){t.value=!0}const r=(...s)=>{t.value&&e(...s)};return{isActive:_n(t),pause:n,resume:o,eventFilter:r}}function hp(...e){if(e.length!==1)return hc(...e);const t=e[0];return typeof t=="function"?_n(fc(()=>({get:t,set:ea}))):be(t)}function mp(e=!1,t={}){const{truthyValue:n=!0,falsyValue:o=!1}=t,r=Ae(e),s=be(e);function i(l){if(arguments.length)return s.value=l,s.value;{const a=Vn(n);return s.value=s.value===a?Vn(o):a,s.value}}return r?i:[s,i]}var vi=Object.getOwnPropertySymbols,vp=Object.prototype.hasOwnProperty,gp=Object.prototype.propertyIsEnumerable,_p=(e,t)=>{var n={};for(var o in e)vp.call(e,o)&&t.indexOf(o)<0&&(n[o]=e[o]);if(e!=null&&vi)for(var o of vi(e))t.indexOf(o)<0&&gp.call(e,o)&&(n[o]=e[o]);return n};function bp(e,t,n={}){const o=n,{eventFilter:r=ta}=o,s=_p(o,["eventFilter"]);return et(e,dp(r,t),s)}var yp=Object.defineProperty,Ep=Object.defineProperties,wp=Object.getOwnPropertyDescriptors,Po=Object.getOwnPropertySymbols,na=Object.prototype.hasOwnProperty,oa=Object.prototype.propertyIsEnumerable,gi=(e,t,n)=>t in e?yp(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,Cp=(e,t)=>{for(var n in t||(t={}))na.call(t,n)&&gi(e,n,t[n]);if(Po)for(var n of Po(t))oa.call(t,n)&&gi(e,n,t[n]);return e},Tp=(e,t)=>Ep(e,wp(t)),Lp=(e,t)=>{var n={};for(var o in e)na.call(e,o)&&t.indexOf(o)<0&&(n[o]=e[o]);if(e!=null&&Po)for(var o of Po(e))t.indexOf(o)<0&&oa.call(e,o)&&(n[o]=e[o]);return n};function xp(e,t,n={}){const o=n,{eventFilter:r}=o,s=Lp(o,["eventFilter"]),{eventFilter:i,pause:l,resume:a,isActive:c}=pp(r);return{stop:bp(e,t,Tp(Cp({},s),{eventFilter:i})),pause:l,resume:a,isActive:c}}function Pp(e){var t;const n=Vn(e);return(t=n==null?void 0:n.$el)!=null?t:n}const Oo=fp?window:void 0;function br(...e){let t,n,o,r;if(typeof e[0]=="string"||Array.isArray(e[0])?([n,o,r]=e,t=Oo):[t,n,o,r]=e,!t)return ea;Array.isArray(n)||(n=[n]),Array.isArray(o)||(o=[o]);const s=[],i=()=>{s.forEach(u=>u()),s.length=0},l=(u,f,p,v)=>(u.addEventListener(f,p,v),()=>u.removeEventListener(f,p,v)),a=et(()=>[Pp(t),Vn(r)],([u,f])=>{i(),u&&s.push(...n.flatMap(p=>o.map(v=>l(u,p,v,f))))},{immediate:!0,flush:"post"}),c=()=>{a(),i()};return Xl(c),c}function Op(){const e=be(!1);return yl()&&We(()=>{e.value=!0}),e}function Sp(e){const t=Op();return z(()=>(t.value,!!e()))}function kp(e,t={}){const{window:n=Oo}=t,o=Sp(()=>n&&"matchMedia"in n&&typeof n.matchMedia=="function");let r;const s=be(!1),i=()=>{r&&("removeEventListener"in r?r.removeEventListener("change",l):r.removeListener(l))},l=()=>{o.value&&(i(),r=n.matchMedia(hp(e).value),s.value=!!(r!=null&&r.matches),r&&("addEventListener"in r?r.addEventListener("change",l):r.addListener(l)))};return el(l),Xl(()=>i()),s}const ao=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},co="__vueuse_ssr_handlers__",Ip=Ap();function Ap(){return co in ao||(ao[co]=ao[co]||{}),ao[co]}function Rp(e,t){return Ip[e]||t}function Dp(e){return e==null?"any":e instanceof Set?"set":e instanceof Map?"map":e instanceof Date?"date":typeof e=="boolean"?"boolean":typeof e=="string"?"string":typeof e=="object"?"object":Number.isNaN(e)?"any":"number"}var $p=Object.defineProperty,_i=Object.getOwnPropertySymbols,Mp=Object.prototype.hasOwnProperty,Np=Object.prototype.propertyIsEnumerable,bi=(e,t,n)=>t in e?$p(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,yi=(e,t)=>{for(var n in t||(t={}))Mp.call(t,n)&&bi(e,n,t[n]);if(_i)for(var n of _i(t))Np.call(t,n)&&bi(e,n,t[n]);return e};const Hp={boolean:{read:e=>e==="true",write:e=>String(e)},object:{read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},number:{read:e=>Number.parseFloat(e),write:e=>String(e)},any:{read:e=>e,write:e=>String(e)},string:{read:e=>e,write:e=>String(e)},map:{read:e=>new Map(JSON.parse(e)),write:e=>JSON.stringify(Array.from(e.entries()))},set:{read:e=>new Set(JSON.parse(e)),write:e=>JSON.stringify(Array.from(e))},date:{read:e=>new Date(e),write:e=>e.toISOString()}},Ei="vueuse-storage";function Bp(e,t,n,o={}){var r;const{flush:s="pre",deep:i=!0,listenToStorageChanges:l=!0,writeDefaults:a=!0,mergeDefaults:c=!1,shallow:u,window:f=Oo,eventFilter:p,onError:v=m=>{console.error(m)}}=o,y=(u?Rr:be)(t);if(!n)try{n=Rp("getDefaultStorage",()=>{var m;return(m=Oo)==null?void 0:m.localStorage})()}catch(m){v(m)}if(!n)return y;const w=Vn(t),x=Dp(w),g=(r=o.serializer)!=null?r:Hp[x],{pause:b,resume:I}=xp(y,()=>k(y.value),{flush:s,deep:i,eventFilter:p});return f&&l&&(br(f,"storage",N),br(f,Ei,ee)),N(),y;function k(m){try{if(m==null)n.removeItem(e);else{const F=g.write(m),B=n.getItem(e);B!==F&&(n.setItem(e,F),f&&f.dispatchEvent(new CustomEvent(Ei,{detail:{key:e,oldValue:B,newValue:F,storageArea:n}})))}}catch(F){v(F)}}function W(m){const F=m?m.newValue:n.getItem(e);if(F==null)return a&&w!==null&&n.setItem(e,g.write(w)),w;if(!m&&c){const B=g.read(F);return typeof c=="function"?c(B,w):x==="object"&&!Array.isArray(B)?yi(yi({},w),B):B}else return typeof F!="string"?F:g.read(F)}function ee(m){N(m.detail)}function N(m){if(!(m&&m.storageArea!==n)){if(m&&m.key==null){y.value=w;return}if(!(m&&m.key!==e)){b();try{y.value=W(m)}catch(F){v(F)}finally{m?Do(I):I()}}}}}function jp(e){return kp("(prefers-color-scheme: dark)",e)}const Vp=()=>Gl(),Fe=()=>Jd(),ra=Symbol(""),Yr=()=>{const e=Oe(ra);if(!e)throw new Error("useDarkMode() is called without provider.");return e},Fp=()=>{const e=Fe(),t=jp(),n=Bp("vuepress-color-scheme",e.value.colorMode),o=z({get(){return e.value.colorModeSwitch?n.value==="auto"?t.value:n.value==="dark":e.value.colorMode==="dark"},set(r){r===t.value?n.value="auto":n.value=r?"dark":"light"}});Wt(ra,o),zp(o)},zp=e=>{const t=(n=e.value)=>{const o=window==null?void 0:window.document.querySelector("html");o==null||o.classList.toggle("dark",n)};We(()=>{et(e,t,{immediate:!0})}),Bo(()=>t())},sa=(...e)=>{const n=Gt().resolve(...e),o=n.matched[n.matched.length-1];if(!(o!=null&&o.redirect))return n;const{redirect:r}=o,s=re(r)?r(n):r,i=me(s)?{path:s}:s;return sa({hash:n.hash,query:n.query,params:n.params,...i})},Gr=e=>{const t=sa(encodeURI(e));return{text:t.meta.title||e,link:t.name==="404"?e:t.fullPath}};let nr=null,Cn=null;const Up={wait:()=>nr,pending:()=>{nr=new Promise(e=>Cn=e)},resolve:()=>{Cn==null||Cn(),nr=null,Cn=null}},ia=()=>Up,la=Symbol("sidebarItems"),Zr=()=>{const e=Oe(la);if(!e)throw new Error("useSidebarItems() is called without provider.");return e},Kp=()=>{const e=Fe(),t=vt(),n=z(()=>qp(t.value,e.value));Wt(la,n)},qp=(e,t)=>{const n=e.sidebar??t.sidebar??"auto",o=e.sidebarDepth??t.sidebarDepth??2;return e.home||n===!1?[]:n==="auto"?Jp(o):q(n)?aa(n,o):Vr(n)?Qp(n,o):[]},Wp=(e,t)=>({text:e.title,link:e.link,children:Xr(e.children,t)}),Xr=(e,t)=>t>0?e.map(n=>Wp(n,t-1)):[],Jp=e=>{const t=$t();return[{text:t.value.title,children:Xr(t.value.headers,e)}]},aa=(e,t)=>{const n=Zt(),o=$t(),r=s=>{var l;let i;if(me(s)?i=Gr(s):i=s,i.children)return{...i,children:i.children.map(a=>r(a))};if(i.link===n.path){const a=((l=o.value.headers[0])==null?void 0:l.level)===1?o.value.headers[0].children:o.value.headers;return{...i,children:Xr(a,t)}}return i};return e.map(s=>r(s))},Qp=(e,t)=>{const n=Zt(),o=Sl(e,n.path),r=e[o]??[];return aa(r,t)},Yp="719px",Gp={mobile:Yp};var Fn;(function(e){e.MOBILE="mobile"})(Fn||(Fn={}));var Li;const Zp={[Fn.MOBILE]:Number.parseInt((Li=Gp.mobile)==null?void 0:Li.replace("px",""),10)},ca=(e,t)=>{const n=Zp[e];Number.isInteger(n)&&We(()=>{t(n),window.addEventListener("resize",()=>t(n),!1),window.addEventListener("orientationchange",()=>t(n),!1)})},Xp={},eh={class:"theme-default-content"};function th(e,t){const n=bt("Content");return H(),Q("div",eh,[ne(n)])}const nh=Le(Xp,[["render",th],["__file","HomeContent.vue"]]),oh={key:0,class:"features"},rh=["innerHTML"],sh=fe({__name:"myHomeFeatures",setup(e){const t=vt(),n=z(()=>q(t.value.features)?t.value.features:[]);return(o,r)=>n.value.length?(H(),Q("div",oh,[(H(!0),Q(Ee,null,yt(n.value,s=>(H(),Q("div",{key:s.title,class:"feature"},[ae("h2",null,Ie(s.title),1),ae("div",{innerHTML:s.details},null,8,rh)]))),128))])):xe("v-if",!0)}}),ih=Le(sh,[["__file","myHomeFeatures.vue"]]),lh=["innerHTML"],ah=["textContent"],ch=fe({__name:"HomeFooter",setup(e){const t=vt(),n=z(()=>t.value.footer),o=z(()=>t.value.footerHtml);return(r,s)=>n.value?(H(),Q(Ee,{key:0},[xe(" eslint-disable-next-line vue/no-v-html "),o.value?(H(),Q("div",{key:0,class:"footer",innerHTML:n.value},null,8,lh)):(H(),Q("div",{key:1,class:"footer",textContent:Ie(n.value)},null,8,ah))],64)):xe("v-if",!0)}}),uh=Le(ch,[["__file","HomeFooter.vue"]]),fh=["href","rel","target","aria-label"],dh=fe({inheritAttrs:!1}),ph=fe({...dh,__name:"AutoLink",props:{item:{type:Object,required:!0}},setup(e){const t=e,n=Zt(),o=Nl(),{item:r}=Dr(t),s=z(()=>Yn(r.value.link)),i=z(()=>of(r.value.link)||rf(r.value.link)),l=z(()=>{if(!i.value){if(r.value.target)return r.value.target;if(s.value)return"_blank"}}),a=z(()=>l.value==="_blank"),c=z(()=>!s.value&&!i.value&&!a.value),u=z(()=>{if(!i.value){if(r.value.rel)return r.value.rel;if(a.value)return"noopener noreferrer"}}),f=z(()=>r.value.ariaLabel||r.value.text),p=z(()=>{const w=Object.keys(o.value.locales);return w.length?!w.some(x=>x===r.value.link):r.value.link!=="/"}),v=z(()=>p.value?n.path.startsWith(r.value.link):!1),y=z(()=>c.value?r.value.activeMatch?new RegExp(r.value.activeMatch).test(n.path):v.value:!1);return(w,x)=>{const g=bt("RouterLink"),b=bt("AutoLinkExternalIcon");return c.value?(H(),Re(g,hr({key:0,class:{"router-link-active":y.value},to:X(r).link,"aria-label":f.value},w.$attrs),{default:Me(()=>[Ce(w.$slots,"before"),Et(" "+Ie(X(r).text)+" ",1),Ce(w.$slots,"after")]),_:3},16,["class","to","aria-label"])):(H(),Q("a",hr({key:1,class:"external-link",href:X(r).link,rel:u.value,target:l.value,"aria-label":f.value},w.$attrs),[Ce(w.$slots,"before"),Et(" "+Ie(X(r).text)+" ",1),a.value?(H(),Re(b,{key:0})):xe("v-if",!0),Ce(w.$slots,"after")],16,fh))}}}),gt=Le(ph,[["__file","AutoLink.vue"]]),hh={class:"hero"},mh={key:0,id:"main-title"},vh={key:1,class:"description"},gh={key:2,class:"actions"},_h=fe({__name:"HomeHero",setup(e){const t=vt(),n=zr(),o=Yr(),r=z(()=>o.value&&t.value.heroImageDark!==void 0?t.value.heroImageDark:t.value.heroImage),s=z(()=>t.value.heroAlt||l.value||"hero"),i=z(()=>t.value.heroHeight||280),l=z(()=>t.value.heroText===null?null:t.value.heroText||n.value.title||"Hello"),a=z(()=>t.value.tagline===null?null:t.value.tagline||n.value.description||"Welcome to your VuePress site"),c=z(()=>q(t.value.actions)?t.value.actions.map(({text:f,link:p,type:v="primary"})=>({text:f,link:p,type:v})):[]),u=()=>{if(!r.value)return null;const f=ge("img",{src:Kr(r.value),alt:s.value,height:i.value});return t.value.heroImageDark===void 0?f:ge(Ur,()=>f)};return(f,p)=>(H(),Q("header",hh,[ne(u),l.value?(H(),Q("h1",mh,Ie(l.value),1)):xe("v-if",!0),a.value?(H(),Q("p",vh,Ie(a.value),1)):xe("v-if",!0),c.value.length?(H(),Q("p",gh,[(H(!0),Q(Ee,null,yt(c.value,v=>(H(),Re(gt,{key:v.text,class:ze(["action-button",[v.type]]),item:v},null,8,["class","item"]))),128))])):xe("v-if",!0)]))}}),bh=Le(_h,[["__file","HomeHero.vue"]]),yh={class:"home"},Eh=fe({__name:"Home",setup(e){return(t,n)=>(H(),Q("main",yh,[ne(bh),ne(ih),ne(nh),ne(uh)]))}}),wh=Le(Eh,[["__file","Home.vue"]]);const Ch={data(){return{selected:void 0,options:[]}},created:async function(){try{let e;const t=sessionStorage.getItem("versions");if(t)try{e=JSON.parse(t)}catch{}if(!e){let o=await(await fetch("https://api.github.com/repos/bcgov/NotifyBC/git/trees/gh-pages")).json();const r=o.tree.find(s=>s.path.toLowerCase()==="version");o=await(await fetch(r.url)).json(),e=o.tree.map(s=>({value:s.path,text:s.path})),e.sort((s,i)=>{const l=s.text.split("."),a=i.text.split(".");for(let c=0;c=0&&(o=r+9);const s=n.indexOf("/",o);window.location.pathname=window.location.pathname.substring(0,9)+t+window.location.pathname.substring(s)}}},Th={key:0},Lh=["value"];function xh(e,t,n,o,r,s){return r.options&&r.options.length>0?(H(),Q("span",Th,[Et(" Version: "),Hn(ae("select",{"onUpdate:modelValue":t[0]||(t[0]=i=>r.selected=i),onChange:t[1]||(t[1]=(...i)=>s.onChange&&s.onChange(...i))},[(H(!0),Q(Ee,null,yt(r.options,i=>(H(),Q("option",{key:i.value,value:i.value},Ie(i.text),9,Lh))),128))],544),[[qu,r.selected]])])):xe("v-if",!0)}const Ph=Le(Ch,[["render",xh],["__scopeId","data-v-888e697c"],["__file","versions.vue"]]),Oh={class:"nb-navbar-brand"},Sh=fe({__name:"myNavbarBrand",setup(e){const t=Gn(),n=zr(),o=Fe(),r=Yr(),s=z(()=>o.value.home||t.value),i=z(()=>n.value.title),l=z(()=>r.value&&o.value.logoDark!==void 0?o.value.logoDark:o.value.logo),a=()=>{if(!l.value)return null;const c=ge("img",{class:"logo",src:Kr(l.value),alt:i.value});return o.value.logoDark===void 0?c:ge(Ur,()=>c)};return(c,u)=>{const f=bt("RouterLink");return H(),Q("div",Oh,[ne(f,{to:s.value},{default:Me(()=>[ne(a)]),_:1},8,["to"]),ne(Ph)])}}});const kh=Le(Sh,[["__file","myNavbarBrand.vue"]]),Ih=fe({__name:"DropdownTransition",setup(e){const t=o=>{o.style.height=o.scrollHeight+"px"},n=o=>{o.style.height=""};return(o,r)=>(H(),Re(Qn,{name:"dropdown",onEnter:t,onAfterEnter:n,onBeforeLeave:t},{default:Me(()=>[Ce(o.$slots,"default")]),_:3}))}}),ua=Le(Ih,[["__file","DropdownTransition.vue"]]),Ah=["aria-label"],Rh={class:"title"},Dh=ae("span",{class:"arrow down"},null,-1),$h=["aria-label"],Mh={class:"title"},Nh={class:"navbar-dropdown"},Hh={class:"navbar-dropdown-subtitle"},Bh={key:1},jh={class:"navbar-dropdown-subitem-wrapper"},Vh=fe({__name:"NavbarDropdown",props:{item:{type:Object,required:!0}},setup(e){const t=e,{item:n}=Dr(t),o=z(()=>n.value.ariaLabel||n.value.text),r=be(!1),s=Zt();et(()=>s.path,()=>{r.value=!1});const i=a=>{a.detail===0?r.value=!r.value:r.value=!1},l=(a,c)=>c[c.length-1]===a;return(a,c)=>(H(),Q("div",{class:ze(["navbar-dropdown-wrapper",{open:r.value}])},[ae("button",{class:"navbar-dropdown-title",type:"button","aria-label":o.value,onClick:i},[ae("span",Rh,Ie(X(n).text),1),Dh],8,Ah),ae("button",{class:"navbar-dropdown-title-mobile",type:"button","aria-label":o.value,onClick:c[0]||(c[0]=u=>r.value=!r.value)},[ae("span",Mh,Ie(X(n).text),1),ae("span",{class:ze(["arrow",r.value?"down":"right"])},null,2)],8,$h),ne(ua,null,{default:Me(()=>[Hn(ae("ul",Nh,[(H(!0),Q(Ee,null,yt(X(n).children,u=>(H(),Q("li",{key:u.text,class:"navbar-dropdown-item"},[u.children?(H(),Q(Ee,{key:0},[ae("h4",Hh,[u.link?(H(),Re(gt,{key:0,item:u,onFocusout:f=>l(u,X(n).children)&&u.children.length===0&&(r.value=!1)},null,8,["item","onFocusout"])):(H(),Q("span",Bh,Ie(u.text),1))]),ae("ul",jh,[(H(!0),Q(Ee,null,yt(u.children,f=>(H(),Q("li",{key:f.link,class:"navbar-dropdown-subitem"},[ne(gt,{item:f,onFocusout:p=>l(f,u.children)&&l(u,X(n).children)&&(r.value=!1)},null,8,["item","onFocusout"])]))),128))])],64)):(H(),Re(gt,{key:1,item:u,onFocusout:f=>l(u,X(n).children)&&(r.value=!1)},null,8,["item","onFocusout"]))]))),128))],512),[[Lo,r.value]])]),_:1})],2))}}),Fh=Le(Vh,[["__file","NavbarDropdown.vue"]]),wi=e=>decodeURI(e).replace(/#.*$/,"").replace(/(index)?\.(md|html)$/,""),zh=(e,t)=>{if(t.hash===e)return!0;const n=wi(t.path),o=wi(e);return n===o},fa=(e,t)=>e.link&&zh(e.link,t)?!0:e.children?e.children.some(n=>fa(n,t)):!1,da=e=>!Yn(e)||/github\.com/.test(e)?"GitHub":/bitbucket\.org/.test(e)?"Bitbucket":/gitlab\.com/.test(e)?"GitLab":/gitee\.com/.test(e)?"Gitee":null,Uh={GitHub:":repo/edit/:branch/:path",GitLab:":repo/-/edit/:branch/:path",Gitee:":repo/edit/:branch/:path",Bitbucket:":repo/src/:branch/:path?mode=edit&spa=0&at=:branch&fileviewer=file-view-default"},Kh=({docsRepo:e,editLinkPattern:t})=>{if(t)return t;const n=da(e);return n!==null?Uh[n]:null},qh=({docsRepo:e,docsBranch:t,docsDir:n,filePathRelative:o,editLinkPattern:r})=>{if(!o)return null;const s=Kh({docsRepo:e,editLinkPattern:r});return s?s.replace(/:repo/,Yn(e)?e:`https://github.com/${e}`).replace(/:branch/,t).replace(/:path/,Ol(`${Pl(n)}/${o}`)):null},Wh={key:0,class:"navbar-items"},Jh=fe({__name:"NavbarItems",setup(e){const t=()=>{const u=Gt(),f=Gn(),p=Nl(),v=zr(),y=Vp(),w=Fe();return z(()=>{const x=Object.keys(p.value.locales);if(x.length<2)return[];const g=u.currentRoute.value.path,b=u.currentRoute.value.fullPath;return[{text:`${w.value.selectLanguageText}`,ariaLabel:`${w.value.selectLanguageAriaLabel??w.value.selectLanguageText}`,children:x.map(k=>{var B,Y;const W=((B=p.value.locales)==null?void 0:B[k])??{},ee=((Y=y.value.locales)==null?void 0:Y[k])??{},N=`${W.lang}`,m=ee.selectLanguageName??N;let F;if(N===v.value.lang)F=b;else{const L=g.replace(f.value,k);u.getRoutes().some(R=>R.path===L)?F=b.replace(g,L):F=ee.home??k}return{text:m,link:F}})}]})},n=()=>{const u=Fe(),f=z(()=>u.value.repo),p=z(()=>f.value?da(f.value):null),v=z(()=>f.value&&!Yn(f.value)?`https://github.com/${f.value}`:f.value),y=z(()=>v.value?u.value.repoLabel?u.value.repoLabel:p.value===null?"Source":p.value:null);return z(()=>!v.value||!y.value?[]:[{text:y.value,link:v.value}])},o=u=>me(u)?Gr(u):u.children?{...u,children:u.children.map(o)}:u,r=()=>{const u=Fe();return z(()=>(u.value.navbar||[]).map(o))},s=be(!1),i=r(),l=t(),a=n(),c=z(()=>[...i.value,...l.value,...a.value]);return ca(Fn.MOBILE,u=>{window.innerWidthc.value.length?(H(),Q("nav",Wh,[(H(!0),Q(Ee,null,yt(c.value,p=>(H(),Q("div",{key:p.text,class:"navbar-item"},[p.children?(H(),Re(Fh,{key:0,item:p,class:ze(s.value?"mobile":"")},null,8,["item","class"])):(H(),Re(gt,{key:1,item:p},null,8,["item"]))]))),128))])):xe("v-if",!0)}}),pa=Le(Jh,[["__file","NavbarItems.vue"]]),Qh=["title"],Yh={class:"icon",focusable:"false",viewBox:"0 0 32 32"},Gh=du('',9),Zh=[Gh],Xh={class:"icon",focusable:"false",viewBox:"0 0 32 32"},em=ae("path",{d:"M13.502 5.414a15.075 15.075 0 0 0 11.594 18.194a11.113 11.113 0 0 1-7.975 3.39c-.138 0-.278.005-.418 0a11.094 11.094 0 0 1-3.2-21.584M14.98 3a1.002 1.002 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.072 13.072 0 0 0 10.703-5.555a1.01 1.01 0 0 0-.783-1.565A13.08 13.08 0 0 1 15.89 4.38A1.015 1.015 0 0 0 14.98 3z",fill:"currentColor"},null,-1),tm=[em],nm=fe({__name:"ToggleColorModeButton",setup(e){const t=Fe(),n=Yr(),o=()=>{n.value=!n.value};return(r,s)=>(H(),Q("button",{class:"toggle-color-mode-button",title:X(t).toggleColorMode,onClick:o},[Hn((H(),Q("svg",Yh,Zh,512)),[[Lo,!X(n)]]),Hn((H(),Q("svg",Xh,tm,512)),[[Lo,X(n)]])],8,Qh))}}),om=Le(nm,[["__file","ToggleColorModeButton.vue"]]),rm=["title"],sm=ae("div",{class:"icon","aria-hidden":"true"},[ae("span"),ae("span"),ae("span")],-1),im=[sm],lm=fe({__name:"ToggleSidebarButton",emits:["toggle"],setup(e){const t=Fe();return(n,o)=>(H(),Q("div",{class:"toggle-sidebar-button",title:X(t).toggleSidebar,"aria-expanded":"false",role:"button",tabindex:"0",onClick:o[0]||(o[0]=r=>n.$emit("toggle"))},im,8,rm))}}),am=Le(lm,[["__file","ToggleSidebarButton.vue"]]),cm=fe({__name:"Navbar",emits:["toggle-sidebar"],setup(e){const t=Fe(),n=be(null),o=be(null),r=be(0),s=z(()=>r.value?{maxWidth:r.value+"px"}:{});ca(Fn.MOBILE,l=>{var c;const a=i(n.value,"paddingLeft")+i(n.value,"paddingRight");window.innerWidth{const c=bt("NavbarSearch");return H(),Q("header",{ref_key:"navbar",ref:n,class:"navbar"},[ne(am,{onToggle:a[0]||(a[0]=u=>l.$emit("toggle-sidebar"))}),ae("span",{ref_key:"navbarBrand",ref:o},[ne(kh)],512),ae("div",{class:"navbar-items-wrapper",style:Qt(s.value)},[Ce(l.$slots,"before"),ne(pa,{class:"can-hide"}),Ce(l.$slots,"after"),X(t).colorModeSwitch?(H(),Re(om,{key:0})):xe("v-if",!0),ne(c)],4)],512)}}}),um=Le(cm,[["__file","Navbar.vue"]]),fm={class:"page-meta"},dm={key:0,class:"meta-item edit-link"},pm={key:1,class:"meta-item last-updated"},hm={class:"meta-item-label"},mm={class:"meta-item-info"},vm={key:2,class:"meta-item contributors"},gm={class:"meta-item-label"},_m={class:"meta-item-info"},bm=["title"],ym=fe({__name:"PageMeta",setup(e){const t=()=>{const a=Fe(),c=$t(),u=vt();return z(()=>{if(!(u.value.editLink??a.value.editLink??!0))return null;const{repo:p,docsRepo:v=p,docsBranch:y="main",docsDir:w="",editLinkText:x}=a.value;if(!v)return null;const g=qh({docsRepo:v,docsBranch:y,docsDir:w,filePathRelative:c.value.filePathRelative,editLinkPattern:u.value.editLinkPattern??a.value.editLinkPattern});return g?{text:x??"Edit this page",link:g}:null})},n=()=>{const a=Fe(),c=$t(),u=vt();return z(()=>{var v,y;return!(u.value.lastUpdated??a.value.lastUpdated??!0)||!((v=c.value.git)!=null&&v.updatedTime)?null:new Date((y=c.value.git)==null?void 0:y.updatedTime).toLocaleString()})},o=()=>{const a=Fe(),c=$t(),u=vt();return z(()=>{var p;return u.value.contributors??a.value.contributors??!0?((p=c.value.git)==null?void 0:p.contributors)??null:null})},r=Fe(),s=t(),i=n(),l=o();return(a,c)=>{const u=bt("ClientOnly");return H(),Q("footer",fm,[X(s)?(H(),Q("div",dm,[ne(gt,{class:"meta-item-label",item:X(s)},null,8,["item"])])):xe("v-if",!0),X(i)?(H(),Q("div",pm,[ae("span",hm,Ie(X(r).lastUpdatedText)+": ",1),ne(u,null,{default:Me(()=>[ae("span",mm,Ie(X(i)),1)]),_:1})])):xe("v-if",!0),X(l)&&X(l).length?(H(),Q("div",vm,[ae("span",gm,Ie(X(r).contributorsText)+": ",1),ae("span",_m,[(H(!0),Q(Ee,null,yt(X(l),(f,p)=>(H(),Q(Ee,{key:p},[ae("span",{class:"contributor",title:`email: ${f.email}`},Ie(f.name),9,bm),p!==X(l).length-1?(H(),Q(Ee,{key:0},[Et(", ")],64)):xe("v-if",!0)],64))),128))])])):xe("v-if",!0)])}}}),Em=Le(ym,[["__file","PageMeta.vue"]]),wm={key:0,class:"page-nav"},Cm={class:"inner"},Tm={key:0,class:"prev"},Lm={key:1,class:"next"},xm=fe({__name:"PageNav",setup(e){const t=a=>a===!1?null:me(a)?Gr(a):Vr(a)?a:!1,n=(a,c,u)=>{const f=a.findIndex(p=>p.link===c);if(f!==-1){const p=a[f+u];return p!=null&&p.link?p:null}for(const p of a)if(p.children){const v=n(p.children,c,u);if(v)return v}return null},o=vt(),r=Zr(),s=Zt(),i=z(()=>{const a=t(o.value.prev);return a!==!1?a:n(r.value,s.path,-1)}),l=z(()=>{const a=t(o.value.next);return a!==!1?a:n(r.value,s.path,1)});return(a,c)=>i.value||l.value?(H(),Q("nav",wm,[ae("p",Cm,[i.value?(H(),Q("span",Tm,[ne(gt,{item:i.value},null,8,["item"])])):xe("v-if",!0),l.value?(H(),Q("span",Lm,[ne(gt,{item:l.value},null,8,["item"])])):xe("v-if",!0)])])):xe("v-if",!0)}}),Pm=Le(xm,[["__file","PageNav.vue"]]),Om={class:"page"},Sm={class:"theme-default-content"},km=fe({__name:"Page",setup(e){return(t,n)=>{const o=bt("Content");return H(),Q("main",Om,[Ce(t.$slots,"top"),ae("div",Sm,[Ce(t.$slots,"content-top"),ne(o),Ce(t.$slots,"content-bottom")]),ne(Em),ne(Pm),Ce(t.$slots,"bottom")])}}}),Im=Le(km,[["__file","Page.vue"]]),Am=["onKeydown"],Rm={class:"sidebar-item-children"},Dm=fe({__name:"SidebarItem",props:{item:{type:Object,required:!0},depth:{type:Number,required:!1,default:0}},setup(e){const t=e,{item:n,depth:o}=Dr(t),r=Zt(),s=Gt(),i=z(()=>fa(n.value,r)),l=z(()=>({"sidebar-item":!0,"sidebar-heading":o.value===0,active:i.value,collapsible:n.value.collapsible})),a=z(()=>n.value.collapsible?i.value:!0),[c,u]=mp(a.value),f=v=>{n.value.collapsible&&(v.preventDefault(),u())},p=s.afterEach(v=>{Do(()=>{c.value=a.value})});return Jn(()=>{p()}),(v,y)=>{var x;const w=bt("SidebarItem",!0);return H(),Q("li",null,[X(n).link?(H(),Re(gt,{key:0,class:ze(l.value),item:X(n)},null,8,["class","item"])):(H(),Q("p",{key:1,tabindex:"0",class:ze(l.value),onClick:f,onKeydown:Ju(f,["enter"])},[Et(Ie(X(n).text)+" ",1),X(n).collapsible?(H(),Q("span",{key:0,class:ze(["arrow",X(c)?"down":"right"])},null,2)):xe("v-if",!0)],42,Am)),(x=X(n).children)!=null&&x.length?(H(),Re(ua,{key:2},{default:Me(()=>[Hn(ae("ul",Rm,[(H(!0),Q(Ee,null,yt(X(n).children,g=>(H(),Re(w,{key:`${X(o)}${g.text}${g.link}`,item:g,depth:X(o)+1},null,8,["item","depth"]))),128))],512),[[Lo,X(c)]])]),_:1})):xe("v-if",!0)])}}}),$m=Le(Dm,[["__file","SidebarItem.vue"]]),Mm={key:0,class:"sidebar-items"},Nm=fe({__name:"SidebarItems",setup(e){const t=Zt(),n=Zr();return We(()=>{et(()=>t.hash,o=>{const r=document.querySelector(".sidebar");if(!r)return;const s=document.querySelector(`.sidebar a.sidebar-item[href="${t.path}${o}"]`);if(!s)return;const{top:i,height:l}=r.getBoundingClientRect(),{top:a,height:c}=s.getBoundingClientRect();ai+l&&s.scrollIntoView(!1)})}),(o,r)=>X(n).length?(H(),Q("ul",Mm,[(H(!0),Q(Ee,null,yt(X(n),s=>(H(),Re($m,{key:`${s.text}${s.link}`,item:s},null,8,["item"]))),128))])):xe("v-if",!0)}}),Hm=Le(Nm,[["__file","SidebarItems.vue"]]),Bm={class:"sidebar"},jm=fe({__name:"Sidebar",setup(e){return(t,n)=>(H(),Q("aside",Bm,[ne(pa),Ce(t.$slots,"top"),ne(Hm),Ce(t.$slots,"bottom")]))}}),Vm=Le(jm,[["__file","Sidebar.vue"]]),Fm=fe({__name:"Layout",setup(e){const t=$t(),n=vt(),o=Fe(),r=z(()=>n.value.navbar!==!1&&o.value.navbar!==!1),s=Zr(),i=be(!1),l=x=>{i.value=typeof x=="boolean"?x:!i.value},a={x:0,y:0},c=x=>{a.x=x.changedTouches[0].clientX,a.y=x.changedTouches[0].clientY},u=x=>{const g=x.changedTouches[0].clientX-a.x,b=x.changedTouches[0].clientY-a.y;Math.abs(g)>Math.abs(b)&&Math.abs(g)>40&&(g>0&&a.x<=80?l(!0):l(!1))},f=z(()=>[{"no-navbar":!r.value,"no-sidebar":!s.value.length,"sidebar-open":i.value},n.value.pageClass]);let p;We(()=>{p=Gt().afterEach(()=>{l(!1)})}),Bo(()=>{p()});const v=ia(),y=v.resolve,w=v.pending;return(x,g)=>(H(),Q("div",{class:ze(["theme-container",f.value]),onTouchstart:c,onTouchend:u},[Ce(x.$slots,"navbar",{},()=>[r.value?(H(),Re(um,{key:0,onToggleSidebar:l},{before:Me(()=>[Ce(x.$slots,"navbar-before")]),after:Me(()=>[Ce(x.$slots,"navbar-after")]),_:3})):xe("v-if",!0)]),ae("div",{class:"sidebar-mask",onClick:g[0]||(g[0]=b=>l(!1))}),Ce(x.$slots,"sidebar",{},()=>[ne(Vm,null,{top:Me(()=>[Ce(x.$slots,"sidebar-top")]),bottom:Me(()=>[Ce(x.$slots,"sidebar-bottom")]),_:3})]),Ce(x.$slots,"page",{},()=>[X(n).home?(H(),Re(wh,{key:0})):(H(),Re(Qn,{key:1,name:"fade-slide-y",mode:"out-in",onBeforeEnter:X(y),onBeforeLeave:X(w)},{default:Me(()=>[(H(),Re(Im,{key:X(t).path},{top:Me(()=>[Ce(x.$slots,"page-top")]),"content-top":Me(()=>[Ce(x.$slots,"page-content-top")]),"content-bottom":Me(()=>[Ce(x.$slots,"page-content-bottom")]),bottom:Me(()=>[Ce(x.$slots,"page-bottom")]),_:3}))]),_:3},8,["onBeforeEnter","onBeforeLeave"]))])],34))}}),zm=Le(Fm,[["__file","Layout.vue"]]),Um={class:"theme-container"},Km={class:"page"},qm={class:"theme-default-content"},Wm=ae("h1",null,"404",-1),Jm=fe({__name:"NotFound",setup(e){const t=Gn(),n=Fe(),o=n.value.notFound??["Not Found"],r=()=>o[Math.floor(Math.random()*o.length)],s=n.value.home??t.value,i=n.value.backToHome??"Back to home";return(l,a)=>{const c=bt("RouterLink");return H(),Q("div",Um,[ae("main",Km,[ae("div",qm,[Wm,ae("blockquote",null,Ie(r()),1),ne(c,{to:X(s)},{default:Me(()=>[Et(Ie(X(i)),1)]),_:1},8,["to"])])])])}}}),Qm=Le(Jm,[["__file","NotFound.vue"]]);const Ym=Ct({enhance({app:e,router:t}){e.component("Badge",Zd),e.component("CodeGroup",Xd),e.component("CodeGroupItem",op),e.component("AutoLinkExternalIcon",()=>{const o=e.component("ExternalLinkIcon");return o?ge(o):null}),e.component("NavbarSearch",()=>{const o=e.component("Docsearch")||e.component("SearchBox");return o?ge(o):null});const n=t.options.scrollBehavior;t.options.scrollBehavior=async(...o)=>(await ia().wait(),n(...o))},setup(){Fp(),Kp()},layouts:{Layout:zm,NotFound:Qm}}),Gm=e=>{const t=br("keydown",n=>{const o=n.key==="k"&&(n.ctrlKey||n.metaKey);!(n.key==="/")&&!o||(n.preventDefault(),e(),t())})},Zm=e=>e.button===1||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey,Xm=()=>{const e=Gt();return{hitComponent:({hit:t,children:n})=>({type:"a",ref:void 0,constructor:void 0,key:void 0,props:{href:t.url,onClick:o=>{Zm(o)||(o.preventDefault(),e.push(zs(t.url,"/NotifyBC/")))},children:n},__v:null}),navigator:{navigate:({itemUrl:t})=>{e.push(zs(t,"/NotifyBC/"))}},transformSearchClient:t=>{const n=qr(t.search,500);return{...t,search:async(...o)=>n(...o)}}}},ev=(e=[],t)=>[`lang:${t}`,...q(e)?e:[e]],tv=({buttonText:e="Search",buttonAriaLabel:t=e}={})=>``,nv=16,ha=()=>{if(document.querySelector(".DocSearch-Modal"))return;const e=new Event("keydown");e.key="k",e.metaKey=!0,window.dispatchEvent(e),setTimeout(ha,nv)},ov=e=>{const t="algolia-preconnect";(window.requestIdleCallback||setTimeout)(()=>{if(document.head.querySelector(`#${t}`))return;const o=document.createElement("link");o.id=t,o.rel="preconnect",o.href=`https://${e}-dsn.algolia.net`,o.crossOrigin="",document.head.appendChild(o)})},rv={apiKey:"c28cbfc8ec48e407e775c3a574dcd775",appId:"JNUID4IQ3B",indexName:"notifybc"};O(()=>import("./style-e9220a04.js"),[]),O(()=>import("./docsearch-1d421ddb.js"),[]);const sv=fe({name:"Docsearch",props:{containerId:{type:String,required:!1,default:"docsearch-container"},options:{type:Object,required:!1,default:()=>rv}},setup(e){const t=Xm(),n=$l(),o=Gn(),r=be(!1),s=be(!1),i=z(()=>{var c;return{...e.options,...(c=e.options.locales)==null?void 0:c[o.value]}}),l=async()=>{var u;const{default:c}=await O(()=>import("./index-5161ad19.js"),[]);c({...t,...i.value,container:`#${e.containerId}`,searchParameters:{...i.value.searchParameters,facetFilters:ev((u=i.value.searchParameters)==null?void 0:u.facetFilters,n.value)}}),r.value=!0},a=()=>{s.value||r.value||(s.value=!0,l(),ha(),et(o,l))};return Gm(a),We(()=>ov(i.value.appId)),()=>{var c;return[ge("div",{id:e.containerId,style:{display:r.value?"block":"none"}}),r.value?null:ge("div",{onClick:a,innerHTML:tv((c=i.value.translations)==null?void 0:c.button)})]}}}),iv=Ct({enhance({app:e}){e.component("Docsearch",sv)}});const lv={name:"CodeCopy",props:{parent:Object,code:String,options:{align:String,color:String,backgroundTransition:Boolean,backgroundColor:String,successText:String,successTextColor:String,staticIcon:Boolean}},data(){return{success:!1,originalBackground:null,originalTransition:null}},computed:{alignStyle(){let e={};return e[this.options.align]="7.5px",e},iconClass(){return this.options.staticIcon?"":"hover"}},mounted(){this.originalTransition=this.parent.style.transition,this.originalBackground=this.parent.style.background},beforeDestroy(){this.parent.style.transition=this.originalTransition,this.parent.style.background=this.originalBackground},methods:{hexToRgb(e){let t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null},copyToClipboard(e){if(navigator.clipboard)navigator.clipboard.writeText(this.code).then(()=>{this.setSuccessTransitions()},()=>{});else{let t=document.createElement("textarea");document.body.appendChild(t),t.value=this.code,t.select(),document.execCommand("Copy"),t.remove(),this.setSuccessTransitions()}},setSuccessTransitions(){if(clearTimeout(this.successTimeout),this.options.backgroundTransition){this.parent.style.transition="background 350ms";let e=this.hexToRgb(this.options.backgroundColor);this.parent.style.background=`rgba(${e.r}, ${e.g}, ${e.b}, 0.1)`}this.success=!0,this.successTimeout=setTimeout(()=>{this.options.backgroundTransition&&(this.parent.style.background=this.originalBackground,this.parent.style.transition=this.originalTransition),this.success=!1},500)}}},av=e=>(Cc("data-v-1b705319"),e=e(),Tc(),e),cv={class:"code-copy"},uv=av(()=>ae("path",{fill:"none",d:"M0 0h24v24H0z"},null,-1)),fv=["fill"];function dv(e,t,n,o,r,s){return H(),Q("div",cv,[(H(),Q("svg",{onClick:t[0]||(t[0]=(...i)=>s.copyToClipboard&&s.copyToClipboard(...i)),xmlns:"http://www.w3.org/2000/svg",width:"24",height:"24",viewBox:"0 0 24 24",class:ze(s.iconClass),style:Qt(s.alignStyle)},[uv,ae("path",{fill:n.options.color,d:"M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm-1 4l6 6v10c0 1.1-.9 2-2 2H7.99C6.89 23 6 22.1 6 21l.01-14c0-1.1.89-2 1.99-2h7zm-1 7h5.5L14 6.5V12z"},null,8,fv)],6)),ae("span",{class:ze(r.success?"success":""),style:Qt({color:n.options.successTextColor,...s.alignStyle})},Ie(n.options.successText),7)])}const Ci=Le(lv,[["render",dv],["__scopeId","data-v-1b705319"],["__file","CodeCopy.vue"]]);const pv=Ct({enhance({app:e}){e.component("CodeCopy",Ci)},setup(){const e=$t(),t=()=>{setTimeout(()=>{document.querySelectorAll('div[class*="language-"]').forEach(n=>{if(n.classList.contains("code-copy-added"))return;let o={align:"bottom",color:"#27b1ff",backgroundTransition:!0,backgroundColor:"#0075b8",successText:"Copied!",successTextColor:"#27b1ff",staticIcon:!1},r=Gu(Ci,{parent:n,code:n.querySelector("pre").innerText,options:o}),s=document.createElement("div");n.appendChild(s),r.mount(s),n.classList.add("code-copy-added")})},500)};We(()=>{t(),window.addEventListener("snippetors-vuepress-plugin-code-copy-update-event",t)}),Jn(()=>{window.removeEventListener("snippetors-vuepress-plugin-code-copy-update-event",t)}),il(()=>{t()}),et(()=>e.value.path,t)}}),uo=[wd,Ld,Sd,Vd,Kd,Yd,Ym,iv,pv],hv=[["v-8daa1a0e","/",{title:""},["/index.md"]],["v-14ac19b5","/help/",{title:""},["/help/index.md"]],["v-e5065f60","/docs/api-administrator/",{title:"Administrator"},["/docs/api/administrator.html","/docs/api/administrator.md"]],["v-eb8745ce","/docs/api-bounce/",{title:"Bounce"},["/docs/api/bounce.html","/docs/api/bounce.md"]],["v-828730c2","/docs/api-config/",{title:"Configuration"},["/docs/api/config.html","/docs/api/config.md"]],["v-563ef996","/docs/api-notification/",{title:"Notification"},["/docs/api/notification.html","/docs/api/notification.md"]],["v-04b96fc8","/docs/api-overview/",{title:"API Overview"},["/docs/api/overview.html","/docs/api/overview.md"]],["v-fe45a0b8","/docs/api-subscription/",{title:"Subscription"},["/docs/api/subscription.html","/docs/api/subscription.md"]],["v-7ed00a2a","/docs/config-adminIpList/",{title:"Admin IP List"},["/docs/config/adminIpList.html","/docs/config/adminIpList.md"]],["v-326db923","/docs/config-certificates/",{title:"TLS Certificates"},["/docs/config/certificates.html","/docs/config/certificates.md"]],["v-587df7db","/docs/config-cronJobs/",{title:"Cron Jobs"},["/docs/config/cronJobs.html","/docs/config/cronJobs.md"]],["v-0b2aad78","/docs/config-database/",{title:"Database"},["/docs/config/database.html","/docs/config/database.md"]],["v-23f62a3a","/docs/config-email/",{title:"Email"},["/docs/config/email.html","/docs/config/email.md"]],["v-1963670f","/docs/config-httpHost/",{title:"HTTP Host"},["/docs/config/httpHost.html","/docs/config/httpHost.md"]],["v-4cf2565c","/docs/config-internalHttpHost/",{title:"Internal HTTP Host"},["/docs/config/internalHttpHost.html","/docs/config/internalHttpHost.md"]],["v-17bdcfe6","/docs/config-middleware/",{title:"Middleware"},["/docs/config/middleware.html","/docs/config/middleware.md"]],["v-3481b484","/docs/config-nodeRoles/",{title:"Node Roles"},["/docs/config/nodeRoles.html","/docs/config/nodeRoles.md"]],["v-b6a1f058","/docs/config-notification/",{title:"Notification"},["/docs/config/notification.html","/docs/config/notification.md"]],["v-94b7dab4","/docs/config-oidc/",{title:"OIDC"},["/docs/config/oidc.html","/docs/config/oidc.md"]],["v-391365f4","/docs/config-overview/",{title:"Configuration Overview"},["/docs/config/overview.html","/docs/config/overview.md"]],["v-32b5e2dd","/docs/config-reverseProxyIpLists/",{title:"Reverse Proxy IP Lists"},["/docs/config/reverseProxyIpLists.html","/docs/config/reverseProxyIpLists.md"]],["v-02a19d2b","/docs/config-rsaKeys/",{title:"RSA Keys"},["/docs/config/rsaKeys.html","/docs/config/rsaKeys.md"]],["v-26e624c6","/docs/config-sms/",{title:"SMS"},["/docs/config/sms.html","/docs/config/sms.md"]],["v-6165843c","/docs/config-subscription/",{title:"Subscription"},["/docs/config/subscription.html","/docs/config/subscription.md"]],["v-22e054a1","/docs/config-workerProcessCount/",{title:"Worker Process Count"},["/docs/config/workerProcessCount.html","/docs/config/workerProcessCount.md"]],["v-147825fb","/docs/",{title:"Welcome"},["/docs/getting-started/","/docs/getting-started/index.md"]],["v-255f131a","/docs/installation/",{title:"Installation"},["/docs/getting-started/installation.html","/docs/getting-started/installation.md"]],["v-6768263b","/docs/overview/",{title:"Overview"},["/docs/getting-started/overview.html","/docs/getting-started/overview.md"]],["v-6a4de75f","/docs/quickstart/",{title:"Quick Start"},["/docs/getting-started/quickstart.html","/docs/getting-started/quickstart.md"]],["v-a20dfce8","/docs/web-console/",{title:"Web Console"},["/docs/getting-started/web-console.html","/docs/getting-started/web-console.md"]],["v-9a955b1e","/docs/what's-new/",{title:"What's New"},["/docs/getting-started/what's-new.html","/docs/getting-started/what's-new.md"]],["v-ca3407c4","/docs/acknowledgments/",{title:"Acknowledgments"},["/docs/meta/acknowledgments.html","/docs/meta/acknowledgments.md"]],["v-3cf0fa66","/docs/conduct/",{title:"Code of Conduct"},["/docs/meta/conduct.html","/docs/meta/conduct.md"]],["v-b09aba04","/docs/benchmarks/",{title:"Benchmarks"},["/docs/miscellaneous/benchmarks.html","/docs/miscellaneous/benchmarks.md"]],["v-b341ee2c","/docs/bulk-import/",{title:"Bulk Import"},["/docs/miscellaneous/bulk-import.html","/docs/miscellaneous/bulk-import.md"]],["v-0c9564ec","/docs/developer-notes/",{title:"Developer Notes"},["/docs/miscellaneous/developer-notes.html","/docs/miscellaneous/developer-notes.md"]],["v-36e2ae9d","/docs/health-check/",{title:"Health Check"},["/docs/miscellaneous/health-check.html","/docs/miscellaneous/health-check.md"]],["v-5b6d532c","/docs/memory-dump/",{title:"Memory Dump"},["/docs/miscellaneous/memory-dump.html","/docs/miscellaneous/memory-dump.md"]],["v-9712b6e4","/docs/upgrade/",{title:"Upgrade Guide"},["/docs/miscellaneous/upgrade.html","/docs/miscellaneous/upgrade.md"]],["v-bdba93e6","/docs/shared/filterQueryParam.html",{title:""},[":md"]],["v-31ddcbc0","/docs/shared/filterQueryParamCode.html",{title:""},[":md"]],["v-0e79de1b","/docs/shared/filterQueryParamExample.html",{title:""},[":md"]],["v-9a1a7988","/docs/shared/jmespathFilter.html",{title:""},[":md"]],["v-4395d380","/docs/shared/throttle.html",{title:""},[":md"]],["v-17bf8008","/docs/shared/whereQueryParam.html",{title:""},[":md"]],["v-0b4a148f","/docs/shared/whereQueryParamCode.html",{title:""},[":md"]],["v-5119194c","/docs/shared/whereQueryParamExample.html",{title:""},[":md"]],["v-3706649a","/404.html",{title:""},[]]];var Ti=fe({name:"Vuepress",setup(){const e=uf();return()=>ge(e.value)}}),mv=()=>hv.reduce((e,[t,n,o,r])=>(e.push({name:t,path:n,component:Ti,meta:o},{path:n.endsWith("/")?n+"index.html":n.substring(0,n.length-5),redirect:n},...r.map(s=>({path:s===":md"?n.substring(0,n.length-5)+".md":s,redirect:n}))),e),[{name:"404",path:"/:catchAll(.*)",component:Ti}]),vv=Af,gv=()=>{const e=md({history:vv(Pl("/NotifyBC/preview/")),routes:mv(),scrollBehavior:(t,n,o)=>o||(t.hash?{el:t.hash}:{top:0})});return e.beforeResolve(async(t,n)=>{var o;(t.path!==n.path||n===ht)&&([It.value]=await Promise.all([pt.resolvePageData(t.name),(o=kl[t.name])==null?void 0:o.__asyncLoader()]))}),e},_v=e=>{e.component("ClientOnly",Ur),e.component("Content",hf)},bv=(e,t,n)=>{const o=z(()=>pt.resolveLayouts(n)),r=mi(()=>t.currentRoute.value.path),s=mi(()=>pt.resolveRouteLocale(on.value.locales,r.value)),i=z(()=>pt.resolveSiteLocaleData(on.value,s.value)),l=z(()=>pt.resolvePageFrontmatter(It.value)),a=z(()=>pt.resolvePageHeadTitle(It.value,i.value)),c=z(()=>pt.resolvePageHead(a.value,l.value,i.value)),u=z(()=>pt.resolvePageLang(It.value,i.value)),f=z(()=>pt.resolvePageLayout(It.value,o.value));return e.provide(sf,o),e.provide(Al,l),e.provide(cf,a),e.provide(Rl,c),e.provide(Dl,u),e.provide(Ml,f),e.provide(Fr,s),e.provide(Hl,i),Object.defineProperties(e.config.globalProperties,{$frontmatter:{get:()=>l.value},$head:{get:()=>c.value},$headTitle:{get:()=>a.value},$lang:{get:()=>u.value},$page:{get:()=>It.value},$routeLocale:{get:()=>s.value},$site:{get:()=>on.value},$siteLocale:{get:()=>i.value},$withBase:{get:()=>Kr}}),{layouts:o,pageData:It,pageFrontmatter:l,pageHead:c,pageHeadTitle:a,pageLang:u,pageLayout:f,routeLocale:s,siteData:on,siteLocaleData:i}},yv=()=>{const e=af(),t=$l(),n=be([]),o=()=>{e.value.forEach(s=>{const i=Ev(s);i&&n.value.push(i)})},r=()=>{document.documentElement.lang=t.value,n.value.forEach(s=>{s.parentNode===document.head&&document.head.removeChild(s)}),n.value.splice(0,n.value.length),e.value.forEach(s=>{const i=wv(s);i!==null&&(document.head.appendChild(i),n.value.push(i))})};Wt(ff,r),We(()=>{o(),r(),et(()=>e.value,r)})},Ev=([e,t,n=""])=>{const o=Object.entries(t).map(([l,a])=>me(a)?`[${l}=${JSON.stringify(a)}]`:a===!0?`[${l}]`:"").join(""),r=`head > ${e}${o}`;return Array.from(document.querySelectorAll(r)).find(l=>l.innerText===n)||null},wv=([e,t,n])=>{if(!me(e))return null;const o=document.createElement(e);return Vr(t)&&Object.entries(t).forEach(([r,s])=>{me(s)?o.setAttribute(r,s):s===!0&&o.setAttribute(r,"")}),me(n)&&o.appendChild(document.createTextNode(n)),o},Cv=Zu,Tv=async()=>{var n;const e=Cv({name:"VuepressApp",setup(){var o;yv();for(const r of uo)(o=r.setup)==null||o.call(r);return()=>[ge(Ql),...uo.flatMap(({rootComponents:r=[]})=>r.map(s=>ge(s)))]}}),t=gv();_v(e),bv(e,t,uo);for(const o of uo)await((n=o.enhance)==null?void 0:n.call(o,{app:e,router:t,siteData:on}));return e.use(t),{app:e,router:t}};Tv().then(({app:e,router:t})=>{t.isReady().then(()=>{e.mount("#app")})});export{Le as _,ae as a,Et as b,Q as c,Tv as createVueApp,ne as d,du as e,Gl as f,H as o,bt as r,Ie as t,X as u,Me as w}; diff --git a/preview/assets/back-to-top-8efcbe56.svg b/preview/assets/back-to-top-8efcbe56.svg new file mode 100644 index 000000000..83236781a --- /dev/null +++ b/preview/assets/back-to-top-8efcbe56.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/preview/assets/docsearch-1d421ddb.js b/preview/assets/docsearch-1d421ddb.js new file mode 100644 index 000000000..182023d2f --- /dev/null +++ b/preview/assets/docsearch-1d421ddb.js @@ -0,0 +1,2 @@ +const i=`@media (min-width: 751px){#docsearch-container{min-width:171.36px}}@media (max-width: 750px){.DocSearch-Container{position:fixed}#docsearch-container{min-width:52px}}@media print{#docsearch-container{display:none}} +`;export{i as default}; diff --git a/preview/assets/filterQueryParam.html-428a3252.js b/preview/assets/filterQueryParam.html-428a3252.js new file mode 100644 index 000000000..6f2f51bac --- /dev/null +++ b/preview/assets/filterQueryParam.html-428a3252.js @@ -0,0 +1,29 @@ +import{_ as r,o as t,c as n,a as e,b as o}from"./app-73097456.js";const s={},l=e("p",null,[o("a filter containing properties "),e("em",null,"where"),o(", "),e("em",null,"fields"),o(", "),e("em",null,"order"),o(", "),e("em",null,"skip"),o(", and "),e("em",null,"limit")],-1),a=e("pre",null,[e("code",null,`- parameter name: filter +- required: false +- parameter type: query +- data type: object + +The filter can be expressed as either + + 1. URL-encoded stringified JSON object (see example below); or + 2. in the format supported by [qs](https://github.com/ljharb/qs), for example \`?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"\` + +Regardless, the filter will have to be parsed into a JSON object conforming to + +\`\`\`json +{ + "where": {...}, + "fields": ..., + "order": ..., + "skip": ..., + "limit": ..., +} +\`\`\` + +All properties are optional. The syntax for each property is documented, respectively +- for *where* , see MongoDB [Query Documents](https://www.mongodb.com/docs/manual/tutorial/query-documents/) +- for *fields* , see Mongoose [select](https://mongoosejs.com/docs/api/query.html#Query.prototype.select()) +- for *order*, see Mongoose [sort](https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()) +- for *skip*, see MongoDB [cursor.skip](https://www.mongodb.com/docs/manual/reference/method/cursor.skip/) +- for *limit*, see MongoDB [cursor.limit](https://www.mongodb.com/docs/manual/reference/method/cursor.limit/) +`)],-1),i=[l,a];function c(m,d){return t(),n("div",null,i)}const u=r(s,[["render",c],["__file","filterQueryParam.html.vue"]]);export{u as default}; diff --git a/preview/assets/filterQueryParam.html-d51cd871.js b/preview/assets/filterQueryParam.html-d51cd871.js new file mode 100644 index 000000000..4c9497cba --- /dev/null +++ b/preview/assets/filterQueryParam.html-d51cd871.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-bdba93e6","path":"/docs/shared/filterQueryParam.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/filterQueryParam.md"}');export{e as data}; diff --git a/preview/assets/filterQueryParamCode.html-18ac53c8.js b/preview/assets/filterQueryParamCode.html-18ac53c8.js new file mode 100644 index 000000000..55d0a9511 --- /dev/null +++ b/preview/assets/filterQueryParamCode.html-18ac53c8.js @@ -0,0 +1 @@ +import{_ as e,o as t,c as r,a as o}from"./app-73097456.js";const a={},c=o("p",null,"?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D",-1),_=[c];function l(s,n){return t(),r("div",null,_)}const f=e(a,[["render",l],["__file","filterQueryParamCode.html.vue"]]);export{f as default}; diff --git a/preview/assets/filterQueryParamCode.html-293978be.js b/preview/assets/filterQueryParamCode.html-293978be.js new file mode 100644 index 000000000..c1fffb4cd --- /dev/null +++ b/preview/assets/filterQueryParamCode.html-293978be.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-31ddcbc0","path":"/docs/shared/filterQueryParamCode.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/filterQueryParamCode.md"}');export{e as data}; diff --git a/preview/assets/filterQueryParamExample.html-95d3a481.js b/preview/assets/filterQueryParamExample.html-95d3a481.js new file mode 100644 index 000000000..ca7a17307 --- /dev/null +++ b/preview/assets/filterQueryParamExample.html-95d3a481.js @@ -0,0 +1,11 @@ +import{_ as t,o as n,c as r,a as e}from"./app-73097456.js";const o={},a=e("p",null,"the value of the filter query parameter is URL-encoded stringified JSON object",-1),l=e("pre",null,[e("code",null,`\`\`\`json +{ + "where": { + "created": { + "$gte": "2023-01-01", + "$lt": "2024-01-01" + } + } +} +\`\`\` +`)],-1),c=[a,l];function s(_,i){return n(),r("div",null,c)}const f=t(o,[["render",s],["__file","filterQueryParamExample.html.vue"]]);export{f as default}; diff --git a/preview/assets/filterQueryParamExample.html-c90c301f.js b/preview/assets/filterQueryParamExample.html-c90c301f.js new file mode 100644 index 000000000..03649b8ef --- /dev/null +++ b/preview/assets/filterQueryParamExample.html-c90c301f.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-0e79de1b","path":"/docs/shared/filterQueryParamExample.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/filterQueryParamExample.md"}');export{e as data}; diff --git a/preview/assets/index-5161ad19.js b/preview/assets/index-5161ad19.js new file mode 100644 index 000000000..17d56fd70 --- /dev/null +++ b/preview/assets/index-5161ad19.js @@ -0,0 +1,17 @@ +/*! @docsearch/js 3.5.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */function cn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function I(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function st(t,e){return function(n){if(Array.isArray(n))return n}(t)||function(n,r){var o=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(o!=null){var i,a,u=[],c=!0,s=!1;try{for(o=o.call(n);!(c=(i=o.next()).done)&&(u.push(i.value),!r||u.length!==r);c=!0);}catch(l){s=!0,a=l}finally{try{c||o.return==null||o.return()}finally{if(s)throw a}}return u}}(t,e)||yr(t,e)||function(){throw new TypeError(`Invalid attempt to destructure non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}()}function ft(t){return function(e){if(Array.isArray(e))return Lt(e)}(t)||function(e){if(typeof Symbol<"u"&&e[Symbol.iterator]!=null||e["@@iterator"]!=null)return Array.from(e)}(t)||yr(t)||function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}()}function yr(t,e){if(t){if(typeof t=="string")return Lt(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return n==="Object"&&t.constructor&&(n=t.constructor.name),n==="Map"||n==="Set"?Array.from(t):n==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Lt(t,e):void 0}}function Lt(t,e){(e==null||e>t.length)&&(e=t.length);for(var n=0,r=new Array(e);n3)for(n=[n],i=3;i0?Pe(v.type,v.props,v.key,null,v.__v):v)!=null){if(v.__=n,v.__b=n.__b+1,(p=b[l])===null||p&&v.key==p.key&&v.type===p.type)b[l]=void 0;else for(m=0;m<_;m++){if((p=b[m])&&v.key==p.key&&v.type===p.type){b[m]=void 0;break}p=null}Zt(t,v,p=p||mt,o,i,a,u,c,s),d=v.__e,(m=v.ref)&&p.ref!=m&&(y||(y=[]),p.ref&&y.push(p.ref,null,v),y.push(m,v.__c||d,v)),d!=null?(h==null&&(h=d),typeof v.type=="function"&&v.__k!=null&&v.__k===p.__k?v.__d=c=jr(v,c,t):c=wr(t,v,p,b,d,c),s||n.type!=="option"?typeof n.type=="function"&&(n.__d=c):t.value=""):c&&p.__e==c&&c.parentNode!=t&&(c=Ve(p))}for(n.__e=h,l=_;l--;)b[l]!=null&&(typeof n.type=="function"&&b[l].__e!=null&&b[l].__e==n.__d&&(n.__d=Ve(r,l+1)),Ir(b[l],b[l]));if(y)for(l=0;l3)for(n=[n],i=3;i=n.__.length&&n.__.push({}),n.__[t]}function kr(t){return pe=1,Ar(xr,t)}function Ar(t,e,n){var r=ze(de++,2);return r.t=t,r.__c||(r.__=[n?n(e):xr(void 0,e),function(o){var i=r.t(r.__[0],o);r.__[0]!==i&&(r.__=[i,r.__[1]],r.__c.setState({}))}],r.__c=q),r.__}function Cr(t,e){var n=ze(de++,3);!j.__s&&Yt(n.__H,e)&&(n.__=t,n.__H=e,q.__H.__h.push(n))}function gn(t,e){var n=ze(de++,4);!j.__s&&Yt(n.__H,e)&&(n.__=t,n.__H=e,q.__h.push(n))}function Pt(t,e){var n=ze(de++,7);return Yt(n.__H,e)&&(n.__=t(),n.__H=e,n.__h=t),n.__}function go(){Ht.forEach(function(t){if(t.__P)try{t.__H.__h.forEach(at),t.__H.__h.forEach(Ut),t.__H.__h=[]}catch(e){t.__H.__h=[],j.__e(e,t.__v)}}),Ht=[]}j.__b=function(t){q=null,pn&&pn(t)},j.__r=function(t){vn&&vn(t),de=0;var e=(q=t.__c).__H;e&&(e.__h.forEach(at),e.__h.forEach(Ut),e.__h=[])},j.diffed=function(t){dn&&dn(t);var e=t.__c;e&&e.__H&&e.__H.__h.length&&(Ht.push(e)!==1&&mn===j.requestAnimationFrame||((mn=j.requestAnimationFrame)||function(n){var r,o=function(){clearTimeout(i),bn&&cancelAnimationFrame(r),setTimeout(n)},i=setTimeout(o,100);bn&&(r=requestAnimationFrame(o))})(go)),q=void 0},j.__c=function(t,e){e.some(function(n){try{n.__h.forEach(at),n.__h=n.__h.filter(function(r){return!r.__||Ut(r)})}catch(r){e.some(function(o){o.__h&&(o.__h=[])}),e=[],j.__e(r,n.__v)}}),hn&&hn(t,e)},j.unmount=function(t){yn&&yn(t);var e=t.__c;if(e&&e.__H)try{e.__H.__.forEach(at)}catch(n){j.__e(n,e.__v)}};var bn=typeof requestAnimationFrame=="function";function at(t){var e=q;typeof t.__c=="function"&&t.__c(),q=e}function Ut(t){var e=q;t.__c=t.__(),q=e}function Yt(t,e){return!t||t.length!==e.length||e.some(function(n,r){return n!==t[r]})}function xr(t,e){return typeof e=="function"?e(t):e}function Nr(t,e){for(var n in e)t[n]=e[n];return t}function Ft(t,e){for(var n in t)if(n!=="__source"&&!(n in e))return!0;for(var r in e)if(r!=="__source"&&t[r]!==e[r])return!0;return!1}function Bt(t){this.props=t}(Bt.prototype=new W).isPureReactComponent=!0,Bt.prototype.shouldComponentUpdate=function(t,e){return Ft(this.props,t)||Ft(this.state,e)};var _n=j.__b;j.__b=function(t){t.type&&t.type.__f&&t.ref&&(t.props.ref=t.ref,t.ref=null),_n&&_n(t)};var bo=typeof Symbol<"u"&&Symbol.for&&Symbol.for("react.forward_ref")||3911,On=function(t,e){return t==null?null:$($(t).map(e))},_o={map:On,forEach:On,count:function(t){return t?$(t).length:0},only:function(t){var e=$(t);if(e.length!==1)throw"Children.only";return e[0]},toArray:$},Oo=j.__e;function ct(){this.__u=0,this.t=null,this.__b=null}function Tr(t){var e=t.__.__c;return e&&e.__e&&e.__e(t)}function je(){this.u=null,this.o=null}j.__e=function(t,e,n){if(t.then){for(var r,o=e;o=o.__;)if((r=o.__c)&&r.__c)return e.__e==null&&(e.__e=n.__e,e.__k=n.__k),r.__c(t,e)}Oo(t,e,n)},(ct.prototype=new W).__c=function(t,e){var n=e.__c,r=this;r.t==null&&(r.t=[]),r.t.push(n);var o=Tr(r.__v),i=!1,a=function(){i||(i=!0,n.componentWillUnmount=n.__c,o?o(u):u())};n.__c=n.componentWillUnmount,n.componentWillUnmount=function(){a(),n.__c&&n.__c()};var u=function(){if(!--r.__u){if(r.state.__e){var s=r.state.__e;r.__v.__k[0]=function m(p,v,d){return p&&(p.__v=null,p.__k=p.__k&&p.__k.map(function(h){return m(h,v,d)}),p.__c&&p.__c.__P===v&&(p.__e&&d.insertBefore(p.__e,p.__d),p.__c.__e=!0,p.__c.__P=d)),p}(s,s.__c.__P,s.__c.__O)}var l;for(r.setState({__e:r.__b=null});l=r.t.pop();)l.forceUpdate()}},c=e.__h===!0;r.__u++||c||r.setState({__e:r.__b=r.__v.__k[0]}),t.then(a,a)},ct.prototype.componentWillUnmount=function(){this.t=[]},ct.prototype.render=function(t,e){if(this.__b){if(this.__v.__k){var n=document.createElement("div"),r=this.__v.__k[0].__c;this.__v.__k[0]=function i(a,u,c){return a&&(a.__c&&a.__c.__H&&(a.__c.__H.__.forEach(function(s){typeof s.__c=="function"&&s.__c()}),a.__c.__H=null),(a=Nr({},a)).__c!=null&&(a.__c.__P===c&&(a.__c.__P=u),a.__c=null),a.__k=a.__k&&a.__k.map(function(s){return i(s,u,c)})),a}(this.__b,n,r.__O=r.__P)}this.__b=null}var o=e.__e&&V(X,null,t.fallback);return o&&(o.__h=null),[V(X,null,e.__e?null:t.children),o]};var Sn=function(t,e,n){if(++n[1]===n[0]&&t.o.delete(e),t.props.revealOrder&&(t.props.revealOrder[0]!=="t"||!t.o.size))for(n=t.u;n;){for(;n.length>3;)n.pop()();if(n[1]>>1,1),e.i.removeChild(r)}}),We(V(So,{context:e.context},t.__v),e.l)):e.l&&e.componentWillUnmount()}function Rr(t,e){return V(jo,{__v:t,i:e})}(je.prototype=new W).__e=function(t){var e=this,n=Tr(e.__v),r=e.o.get(t);return r[0]++,function(o){var i=function(){e.props.revealOrder?(r.push(o),Sn(e,t,r)):o()};n?n(i):i()}},je.prototype.render=function(t){this.u=null,this.o=new Map;var e=$(t.children);t.revealOrder&&t.revealOrder[0]==="b"&&e.reverse();for(var n=e.length;n--;)this.o.set(e[n],this.u=[1,0,this.u]);return t.children},je.prototype.componentDidUpdate=je.prototype.componentDidMount=function(){var t=this;this.o.forEach(function(e,n){Sn(t,n,e)})};var qr=typeof Symbol<"u"&&Symbol.for&&Symbol.for("react.element")||60103,wo=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,Eo=function(t){return(typeof Symbol<"u"&&Be(Symbol())=="symbol"?/fil|che|rad/i:/fil|che|ra/i).test(t)};function Lr(t,e,n){return e.__k==null&&(e.textContent=""),We(t,e),typeof n=="function"&&n(),t?t.__c:null}W.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach(function(t){Object.defineProperty(W.prototype,t,{configurable:!0,get:function(){return this["UNSAFE_"+t]},set:function(e){Object.defineProperty(this,t,{configurable:!0,writable:!0,value:e})}})});var jn=j.event;function Po(){}function Io(){return this.cancelBubble}function Do(){return this.defaultPrevented}j.event=function(t){return jn&&(t=jn(t)),t.persist=Po,t.isPropagationStopped=Io,t.isDefaultPrevented=Do,t.nativeEvent=t};var Mr,wn={configurable:!0,get:function(){return this.class}},En=j.vnode;j.vnode=function(t){var e=t.type,n=t.props,r=n;if(typeof e=="string"){for(var o in r={},n){var i=n[o];o==="value"&&"defaultValue"in n&&i==null||(o==="defaultValue"&&"value"in n&&n.value==null?o="value":o==="download"&&i===!0?i="":/ondoubleclick/i.test(o)?o="ondblclick":/^onchange(textarea|input)/i.test(o+e)&&!Eo(n.type)?o="oninput":/^on(Ani|Tra|Tou|BeforeInp)/.test(o)?o=o.toLowerCase():wo.test(o)?o=o.replace(/[A-Z0-9]/,"-$&").toLowerCase():i===null&&(i=void 0),r[o]=i)}e=="select"&&r.multiple&&Array.isArray(r.value)&&(r.value=$(n.children).forEach(function(a){a.props.selected=r.value.indexOf(a.props.value)!=-1})),e=="select"&&r.defaultValue!=null&&(r.value=$(n.children).forEach(function(a){a.props.selected=r.multiple?r.defaultValue.indexOf(a.props.value)!=-1:r.defaultValue==a.props.value})),t.props=r}e&&n.class!=n.className&&(wn.enumerable="className"in n,n.className!=null&&(r.class=n.className),Object.defineProperty(r,"className",wn)),t.$$typeof=qr,En&&En(t)};var Pn=j.__r;j.__r=function(t){Pn&&Pn(t),Mr=t.__c};var ko={ReactCurrentDispatcher:{current:{readContext:function(t){return Mr.__n[t.__c].props.value}}}};(typeof performance>"u"?"undefined":Be(performance))=="object"&&typeof performance.now=="function"&&performance.now.bind(performance);function In(t){return!!t&&t.$$typeof===qr}var f={useState:kr,useReducer:Ar,useEffect:Cr,useLayoutEffect:gn,useRef:function(t){return pe=5,Pt(function(){return{current:t}},[])},useImperativeHandle:function(t,e,n){pe=6,gn(function(){typeof t=="function"?t(e()):t&&(t.current=e())},n==null?n:n.concat(t))},useMemo:Pt,useCallback:function(t,e){return pe=8,Pt(function(){return t},e)},useContext:function(t){var e=q.context[t.__c],n=ze(de++,9);return n.__c=t,e?(n.__==null&&(n.__=!0,e.sub(q)),e.props.value):t.__},useDebugValue:function(t,e){j.useDebugValue&&j.useDebugValue(e?e(t):t)},version:"16.8.0",Children:_o,render:Lr,hydrate:function(t,e,n){return Dr(t,e),typeof n=="function"&&n(),t?t.__c:null},unmountComponentAtNode:function(t){return!!t.__k&&(We(null,t),!0)},createPortal:Rr,createElement:V,createContext:function(t,e){var n={__c:e="__cC"+br++,__:t,Consumer:function(r,o){return r.children(o)},Provider:function(r){var o,i;return this.getChildContext||(o=[],(i={})[e]=this,this.getChildContext=function(){return i},this.shouldComponentUpdate=function(a){this.props.value!==a.value&&o.some(Mt)},this.sub=function(a){o.push(a);var u=a.componentWillUnmount;a.componentWillUnmount=function(){o.splice(o.indexOf(a),1),u&&u.call(a)}}),r.children}};return n.Provider.__=n.Consumer.contextType=n},createFactory:function(t){return V.bind(null,t)},cloneElement:function(t){return In(t)?yo.apply(null,arguments):t},createRef:function(){return{current:null}},Fragment:X,isValidElement:In,findDOMNode:function(t){return t&&(t.base||t.nodeType===1&&t)||null},Component:W,PureComponent:Bt,memo:function(t,e){function n(o){var i=this.props.ref,a=i==o.ref;return!a&&i&&(i.call?i(null):i.current=null),e?!e(this.props,o)||!a:Ft(this.props,o)}function r(o){return this.shouldComponentUpdate=n,V(t,o)}return r.displayName="Memo("+(t.displayName||t.name)+")",r.prototype.isReactComponent=!0,r.__f=!0,r},forwardRef:function(t){function e(n,r){var o=Nr({},n);return delete o.ref,t(o,(r=n.ref||r)&&(Be(r)!="object"||"current"in r)?r:null)}return e.$$typeof=bo,e.render=e,e.prototype.isReactComponent=e.__f=!0,e.displayName="ForwardRef("+(t.displayName||t.name)+")",e},unstable_batchedUpdates:function(t,e){return t(e)},StrictMode:X,Suspense:ct,SuspenseList:je,lazy:function(t){var e,n,r;function o(i){if(e||(e=t()).then(function(a){n=a.default||a},function(a){r=a}),r)throw r;if(!n)throw e;return V(n,i)}return o.displayName="Lazy",o.__f=!0,o},__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:ko};function Ao(){return f.createElement("svg",{width:"15",height:"15",className:"DocSearch-Control-Key-Icon"},f.createElement("path",{d:"M4.505 4.496h2M5.505 5.496v5M8.216 4.496l.055 5.993M10 7.5c.333.333.5.667.5 1v2M12.326 4.5v5.996M8.384 4.496c1.674 0 2.116 0 2.116 1.5s-.442 1.5-2.116 1.5M3.205 9.303c-.09.448-.277 1.21-1.241 1.203C1 10.5.5 9.513.5 8V7c0-1.57.5-2.5 1.464-2.494.964.006 1.134.598 1.24 1.342M12.553 10.5h1.953",strokeWidth:"1.2",stroke:"currentColor",fill:"none",strokeLinecap:"square"}))}function Hr(){return f.createElement("svg",{width:"20",height:"20",className:"DocSearch-Search-Icon",viewBox:"0 0 20 20"},f.createElement("path",{d:"M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}var Co=["translations"];function Vt(){return Vt=Object.assign||function(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}var To=f.forwardRef(function(t,e){var n=t.translations,r=n===void 0?{}:n,o=No(t,Co),i=r.buttonText,a=i===void 0?"Search":i,u=r.buttonAriaLabel,c=u===void 0?"Search":u,s=xo(kr(null),2),l=s[0],m=s[1];return Cr(function(){typeof navigator<"u"&&(/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)?m("⌘"):m("Ctrl"))},[]),f.createElement("button",Vt({type:"button",className:"DocSearch DocSearch-Button","aria-label":c},o,{ref:e}),f.createElement("span",{className:"DocSearch-Button-Container"},f.createElement(Hr,null),f.createElement("span",{className:"DocSearch-Button-Placeholder"},a)),f.createElement("span",{className:"DocSearch-Button-Keys"},l!==null&&f.createElement(f.Fragment,null,f.createElement("kbd",{className:"DocSearch-Button-Key"},l==="Ctrl"?f.createElement(Ao,null):l),f.createElement("kbd",{className:"DocSearch-Button-Key"},"K"))))});function Ur(t,e){var n=void 0;return function(){for(var r=arguments.length,o=new Array(r),i=0;it.length)&&(e=t.length);for(var n=0,r=new Array(e);nt.length)&&(e=t.length);for(var n=0,r=new Array(e);n=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function xn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function ve(t){for(var e=1;e1&&arguments[1]!==void 0?arguments[1]:20,n=[],r=0;r=3||n===2&&r>=4||n===1&&r>=10);function i(a,u,c){if(o&&c!==void 0){var s=c[0].__autocomplete_algoliaCredentials,l={"X-Algolia-Application-Id":s.appId,"X-Algolia-API-Key":s.apiKey};t.apply(void 0,[a].concat(Ye(u),[{headers:l}]))}else t.apply(void 0,[a].concat(Ye(u)))}return{init:function(a,u){t("init",{appId:a,apiKey:u})},setUserToken:function(a){t("setUserToken",a)},clickedObjectIDsAfterSearch:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&i("clickedObjectIDsAfterSearch",Ge(u),u[0].items)},clickedObjectIDs:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&i("clickedObjectIDs",Ge(u),u[0].items)},clickedFilters:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&t.apply(void 0,["clickedFilters"].concat(u))},convertedObjectIDsAfterSearch:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&i("convertedObjectIDsAfterSearch",Ge(u),u[0].items)},convertedObjectIDs:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&i("convertedObjectIDs",Ge(u),u[0].items)},convertedFilters:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&t.apply(void 0,["convertedFilters"].concat(u))},viewedObjectIDs:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&u.reduce(function(s,l){var m=l.items,p=Br(l,Ho);return[].concat(Ye(s),Ye(Fo(ve(ve({},p),{},{objectIDs:(m==null?void 0:m.map(function(v){return v.objectID}))||p.objectIDs})).map(function(v){return{items:m,payload:v}})))},[]).forEach(function(s){var l=s.items;return i("viewedObjectIDs",[s.payload],l)})},viewedFilters:function(){for(var a=arguments.length,u=new Array(a),c=0;c0&&t.apply(void 0,["viewedFilters"].concat(u))}}}function Vo(t){var e=t.items.reduce(function(n,r){var o;return n[r.__autocomplete_indexName]=((o=n[r.__autocomplete_indexName])!==null&&o!==void 0?o:[]).concat(r),n},{});return Object.keys(e).map(function(n){return{index:n,items:e[n],algoliaSource:["autocomplete"]}})}function Dt(t){return t.objectID&&t.__autocomplete_indexName&&t.__autocomplete_queryID}function De(t){return De=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},De(t)}function ae(t){return function(e){if(Array.isArray(e))return kt(e)}(t)||function(e){if(typeof Symbol<"u"&&e[Symbol.iterator]!=null||e["@@iterator"]!=null)return Array.from(e)}(t)||function(e,n){if(e){if(typeof e=="string")return kt(e,n);var r=Object.prototype.toString.call(e).slice(8,-1);if(r==="Object"&&e.constructor&&(r=e.constructor.name),r==="Map"||r==="Set")return Array.from(e);if(r==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return kt(e,n)}}(t)||function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}()}function kt(t,e){(e==null||e>t.length)&&(e=t.length);for(var n=0,r=new Array(e);n0&&zo({onItemsChange:r,items:p,insights:u,state:m}))}},0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(l){var m=l.setContext,p=l.onSelect,v=l.onActive;a("addAlgoliaAgent","insights-plugin"),m({algoliaInsightsPlugin:{__algoliaSearchParameters:{clickAnalytics:!0},insights:u}}),p(function(d){var h=d.item,y=d.state,b=d.event;Dt(h)&&o({state:y,event:b,insights:u,item:h,insightsEvents:[G({eventName:"Item Selected"},An({item:h,items:c.current}))]})}),v(function(d){var h=d.item,y=d.state,b=d.event;Dt(h)&&i({state:y,event:b,insights:u,item:h,insightsEvents:[G({eventName:"Item Active"},An({item:h,items:c.current}))]})})},onStateChange:function(l){var m=l.state;s({state:m})},__autocomplete_pluginOptions:t}}function ut(t,e){var n=e;return{then:function(r,o){return ut(t.then(Xe(r,n,t),Xe(o,n,t)),n)},catch:function(r){return ut(t.catch(Xe(r,n,t)),n)},finally:function(r){return r&&n.onCancelList.push(r),ut(t.finally(Xe(r&&function(){return n.onCancelList=[],r()},n,t)),n)},cancel:function(){n.isCanceled=!0;var r=n.onCancelList;n.onCancelList=[],r.forEach(function(o){o()})},isCanceled:function(){return n.isCanceled===!0}}}function Tn(t){return ut(t,{isCanceled:!1,onCancelList:[]})}function Xe(t,e,n){return t?function(r){return e.isCanceled?r:t(r)}:n}function Rn(t,e,n,r){if(!n)return null;if(t<0&&(e===null||r!==null&&e===0))return n+t;var o=(e===null?-1:e)+t;return o<=-1||o>=n?r===null?null:0:o}function qn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function Ln(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n0},reshape:function(i){return i.sources}},t),{},{id:(n=t.id)!==null&&n!==void 0?n:"autocomplete-".concat(Ro++),plugins:o,initialState:ce({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},t.initialState),onStateChange:function(i){var a;(a=t.onStateChange)===null||a===void 0||a.call(t,i),o.forEach(function(u){var c;return(c=u.onStateChange)===null||c===void 0?void 0:c.call(u,i)})},onSubmit:function(i){var a;(a=t.onSubmit)===null||a===void 0||a.call(t,i),o.forEach(function(u){var c;return(c=u.onSubmit)===null||c===void 0?void 0:c.call(u,i)})},onReset:function(i){var a;(a=t.onReset)===null||a===void 0||a.call(t,i),o.forEach(function(u){var c;return(c=u.onReset)===null||c===void 0?void 0:c.call(u,i)})},getSources:function(i){return Promise.all([].concat(Xo(o.map(function(a){return a.getSources})),[t.getSources]).filter(Boolean).map(function(a){return function(u,c){var s=[];return Promise.resolve(u(c)).then(function(l){return Promise.all(l.filter(function(m){return!!m}).map(function(m){if(m.sourceId,s.includes(m.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(m.sourceId)," is not unique."));s.push(m.sourceId);var p={getItemInputValue:function(d){return d.state.query},getItemUrl:function(){},onSelect:function(d){(0,d.setIsOpen)(!1)},onActive:vt,onResolve:vt};Object.keys(p).forEach(function(d){p[d].__default=!0});var v=Ln(Ln({},p),m);return Promise.resolve(v)}))})}(a,i)})).then(function(a){return Ke(a)}).then(function(a){return a.map(function(u){return ce(ce({},u),{},{onSelect:function(c){u.onSelect(c),e.forEach(function(s){var l;return(l=s.onSelect)===null||l===void 0?void 0:l.call(s,c)})},onActive:function(c){u.onActive(c),e.forEach(function(s){var l;return(l=s.onActive)===null||l===void 0?void 0:l.call(s,c)})},onResolve:function(c){u.onResolve(c),e.forEach(function(s){var l;return(l=s.onResolve)===null||l===void 0?void 0:l.call(s,c)})}})})})},navigator:ce({navigate:function(i){var a=i.itemUrl;r.location.assign(a)},navigateNewTab:function(i){var a=i.itemUrl,u=r.open(a,"_blank","noopener");u==null||u.focus()},navigateNewWindow:function(i){var a=i.itemUrl;r.open(a,"_blank","noopener")}},t.navigator)})}function Ne(t){return Ne=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Ne(t)}function Fn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function tt(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}var Wn,xt,rt,Se=null,Kn=(Wn=-1,xt=-1,rt=void 0,function(t){var e=++Wn;return Promise.resolve(t).then(function(n){return rt&&e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function Le(t){return Le=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Le(t)}var mi=["props","refresh","store"],pi=["inputElement","formElement","panelElement"],vi=["inputElement"],di=["inputElement","maxLength"],hi=["sourceIndex"],yi=["sourceIndex"],gi=["item","source","sourceIndex"];function Jn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function R(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function _i(t){var e=t.props,n=t.refresh,r=t.store,o=ne(t,mi),i=function(a,u){return u!==void 0?"".concat(a,"-").concat(u):a};return{getEnvironmentProps:function(a){var u=a.inputElement,c=a.formElement,s=a.panelElement;function l(m){!r.getState().isOpen&&r.pendingRequests.isEmpty()||m.target===u||[c,s].some(function(p){return v=p,d=m.target,v===d||v.contains(d);var v,d})===!1&&(r.dispatch("blur",null),e.debug||r.pendingRequests.cancelAll())}return R({onTouchStart:l,onMouseDown:l,onTouchMove:function(m){r.getState().isOpen!==!1&&u===e.environment.document.activeElement&&m.target!==u&&u.blur()}},ne(a,pi))},getRootProps:function(a){return R({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-owns":r.getState().isOpen?"".concat(e.id,"-list"):void 0,"aria-labelledby":"".concat(e.id,"-label")},a)},getFormProps:function(a){return a.inputElement,R({action:"",noValidate:!0,role:"search",onSubmit:function(u){var c;u.preventDefault(),e.onSubmit(R({event:u,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),(c=a.inputElement)===null||c===void 0||c.blur()},onReset:function(u){var c;u.preventDefault(),e.onReset(R({event:u,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),(c=a.inputElement)===null||c===void 0||c.focus()}},ne(a,vi))},getLabelProps:function(a){var u=a||{},c=u.sourceIndex,s=ne(u,hi);return R({htmlFor:"".concat(i(e.id,c),"-input"),id:"".concat(i(e.id,c),"-label")},s)},getInputProps:function(a){var u;function c(y){(e.openOnFocus||r.getState().query)&&se(R({event:y,props:e,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var s=a||{},l=(s.inputElement,s.maxLength),m=l===void 0?512:l,p=ne(s,di),v=fe(r.getState()),d=function(y){return!!(y&&y.match(Qo))}(((u=e.environment.navigator)===null||u===void 0?void 0:u.userAgent)||""),h=v!=null&&v.itemUrl&&!d?"go":"search";return R({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&r.getState().activeItemId!==null?"".concat(e.id,"-item-").concat(r.getState().activeItemId):void 0,"aria-controls":r.getState().isOpen?"".concat(e.id,"-list"):void 0,"aria-labelledby":"".concat(e.id,"-label"),value:r.getState().completion||r.getState().query,id:"".concat(e.id,"-input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:h,spellCheck:"false",autoFocus:e.autoFocus,placeholder:e.placeholder,maxLength:m,type:"search",onChange:function(y){se(R({event:y,props:e,query:y.currentTarget.value.slice(0,m),refresh:n,store:r},o))},onKeyDown:function(y){(function(b){var _=b.event,S=b.props,O=b.refresh,g=b.store,P=fi(b,li);if(_.key==="ArrowUp"||_.key==="ArrowDown"){var C=function(){var M=S.environment.document.getElementById("".concat(S.id,"-item-").concat(g.getState().activeItemId));M&&(M.scrollIntoViewIfNeeded?M.scrollIntoViewIfNeeded(!1):M.scrollIntoView(!1))},L=function(){var M=fe(g.getState());if(g.getState().activeItemId!==null&&M){var Ot=M.item,St=M.itemInputValue,Je=M.itemUrl,B=M.source;B.onActive(te({event:_,item:Ot,itemInputValue:St,itemUrl:Je,refresh:O,source:B,state:g.getState()},P))}};_.preventDefault(),g.getState().isOpen===!1&&(S.openOnFocus||g.getState().query)?se(te({event:_,props:S,query:g.getState().query,refresh:O,store:g},P)).then(function(){g.dispatch(_.key,{nextActiveItemId:S.defaultActiveItemId}),L(),setTimeout(C,0)}):(g.dispatch(_.key,{}),L(),C())}else if(_.key==="Escape")_.preventDefault(),g.dispatch(_.key,null),g.pendingRequests.cancelAll();else if(_.key==="Tab")g.dispatch("blur",null),g.pendingRequests.cancelAll();else if(_.key==="Enter"){if(g.getState().activeItemId===null||g.getState().collections.every(function(M){return M.items.length===0}))return void(S.debug||g.pendingRequests.cancelAll());_.preventDefault();var x=fe(g.getState()),k=x.item,N=x.itemInputValue,U=x.itemUrl,F=x.source;if(_.metaKey||_.ctrlKey)U!==void 0&&(F.onSelect(te({event:_,item:k,itemInputValue:N,itemUrl:U,refresh:O,source:F,state:g.getState()},P)),S.navigator.navigateNewTab({itemUrl:U,item:k,state:g.getState()}));else if(_.shiftKey)U!==void 0&&(F.onSelect(te({event:_,item:k,itemInputValue:N,itemUrl:U,refresh:O,source:F,state:g.getState()},P)),S.navigator.navigateNewWindow({itemUrl:U,item:k,state:g.getState()}));else if(!_.altKey){if(U!==void 0)return F.onSelect(te({event:_,item:k,itemInputValue:N,itemUrl:U,refresh:O,source:F,state:g.getState()},P)),void S.navigator.navigate({itemUrl:U,item:k,state:g.getState()});se(te({event:_,nextState:{isOpen:!1},props:S,query:N,refresh:O,store:g},P)).then(function(){F.onSelect(te({event:_,item:k,itemInputValue:N,itemUrl:U,refresh:O,source:F,state:g.getState()},P))})}}})(R({event:y,props:e,refresh:n,store:r},o))},onFocus:c,onBlur:vt,onClick:function(y){a.inputElement!==e.environment.document.activeElement||r.getState().isOpen||c(y)}},p)},getPanelProps:function(a){return R({onMouseDown:function(u){u.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},a)},getListProps:function(a){var u=a||{},c=u.sourceIndex,s=ne(u,yi);return R({role:"listbox","aria-labelledby":"".concat(i(e.id,c),"-label"),id:"".concat(i(e.id,c),"-list")},s)},getItemProps:function(a){var u=a.item,c=a.source,s=a.sourceIndex,l=ne(a,gi);return R({id:"".concat(i(e.id,s),"-item-").concat(u.__autocomplete_id),role:"option","aria-selected":r.getState().activeItemId===u.__autocomplete_id,onMouseMove:function(m){if(u.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",u.__autocomplete_id);var p=fe(r.getState());if(r.getState().activeItemId!==null&&p){var v=p.item,d=p.itemInputValue,h=p.itemUrl,y=p.source;y.onActive(R({event:m,item:v,itemInputValue:d,itemUrl:h,refresh:n,source:y,state:r.getState()},o))}}},onMouseDown:function(m){m.preventDefault()},onClick:function(m){var p=c.getItemInputValue({item:u,state:r.getState()}),v=c.getItemUrl({item:u,state:r.getState()});(v?Promise.resolve():se(R({event:m,nextState:{isOpen:!1},props:e,query:p,refresh:n,store:r},o))).then(function(){c.onSelect(R({event:m,item:u,itemInputValue:p,itemUrl:v,refresh:n,source:c,state:r.getState()},o))})}},l)}}}function Me(t){return Me=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Me(t)}function $n(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function Oi(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function Vi(t){var e=t.translations,n=e===void 0?{}:e,r=Bi(t,Ui),o=n.noResultsText,i=o===void 0?"No results for":o,a=n.suggestedQueryText,u=a===void 0?"Try searching for":a,c=n.reportMissingResultsText,s=c===void 0?"Believe this query should return results?":c,l=n.reportMissingResultsLinkText,m=l===void 0?"Let us know.":l,p=r.state.context.searchSuggestions;return f.createElement("div",{className:"DocSearch-NoResults"},f.createElement("div",{className:"DocSearch-Screen-Icon"},f.createElement(Mi,null)),f.createElement("p",{className:"DocSearch-Title"},i,' "',f.createElement("strong",null,r.state.query),'"'),p&&p.length>0&&f.createElement("div",{className:"DocSearch-NoResults-Prefill-List"},f.createElement("p",{className:"DocSearch-Help"},u,":"),f.createElement("ul",null,p.slice(0,3).reduce(function(v,d){return[].concat(Fi(v),[f.createElement("li",{key:d},f.createElement("button",{className:"DocSearch-Prefill",key:d,type:"button",onClick:function(){r.setQuery(d.toLowerCase()+" "),r.refresh(),r.inputRef.current.focus()}},d))])},[]))),r.getMissingResultsUrl&&f.createElement("p",{className:"DocSearch-Help"},"".concat(s," "),f.createElement("a",{href:r.getMissingResultsUrl({query:r.state.query}),target:"_blank",rel:"noopener noreferrer"},m)))}var Wi=["hit","attribute","tagName"];function Xn(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(o){return Object.getOwnPropertyDescriptor(t,o).enumerable})),n.push.apply(n,r)}return n}function er(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function tr(t,e){return e.split(".").reduce(function(n,r){return n!=null&&n[r]?n[r]:null},t)}function le(t){var e=t.hit,n=t.attribute,r=t.tagName;return V(r===void 0?"span":r,er(er({},zi(t,Wi)),{},{dangerouslySetInnerHTML:{__html:tr(e,"_snippetResult.".concat(n,".value"))||tr(e,n)}}))}function nr(t,e){return function(n){if(Array.isArray(n))return n}(t)||function(n,r){var o=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(o!=null){var i,a,u=[],c=!0,s=!1;try{for(o=o.call(n);!(c=(i=o.next()).done)&&(u.push(i.value),!r||u.length!==r);c=!0);}catch(l){s=!0,a=l}finally{try{c||o.return==null||o.return()}finally{if(s)throw a}}return u}}(t,e)||function(n,r){if(n){if(typeof n=="string")return rr(n,r);var o=Object.prototype.toString.call(n).slice(8,-1);if(o==="Object"&&n.constructor&&(o=n.constructor.name),o==="Map"||o==="Set")return Array.from(n);if(o==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(o))return rr(n,r)}}(t,e)||function(){throw new TypeError(`Invalid attempt to destructure non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}()}function rr(t,e){(e==null||e>t.length)&&(e=t.length);for(var n=0,r=new Array(e);n|<\/mark>)/g,Qi=RegExp(zr.source);function Jr(t){var e,n,r,o,i,a=t;if(!a.__docsearch_parent&&!t._highlightResult)return t.hierarchy.lvl0;var u=((a.__docsearch_parent?(e=a.__docsearch_parent)===null||e===void 0||(n=e._highlightResult)===null||n===void 0||(r=n.hierarchy)===null||r===void 0?void 0:r.lvl0:(o=t._highlightResult)===null||o===void 0||(i=o.hierarchy)===null||i===void 0?void 0:i.lvl0)||{}).value;return u&&Qi.test(u)?u.replace(zr,""):u}function Jt(){return Jt=Object.assign||function(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function Xi(t){var e=t.translations,n=e===void 0?{}:e,r=Gi(t,Yi),o=n.recentSearchesTitle,i=o===void 0?"Recent":o,a=n.noRecentSearchesText,u=a===void 0?"No recent searches":a,c=n.saveRecentSearchButtonTitle,s=c===void 0?"Save this search":c,l=n.removeRecentSearchButtonTitle,m=l===void 0?"Remove this search from history":l,p=n.favoriteSearchesTitle,v=p===void 0?"Favorite":p,d=n.removeFavoriteSearchButtonTitle,h=d===void 0?"Remove this search from favorites":d;return r.state.status==="idle"&&r.hasCollections===!1?r.disableUserPersonalization?null:f.createElement("div",{className:"DocSearch-StartScreen"},f.createElement("p",{className:"DocSearch-Help"},u)):r.hasCollections===!1?null:f.createElement("div",{className:"DocSearch-Dropdown-Container"},f.createElement(zt,ht({},r,{title:i,collection:r.state.collections[0],renderIcon:function(){return f.createElement("div",{className:"DocSearch-Hit-icon"},f.createElement(Ci,null))},renderAction:function(y){var b=y.item,_=y.runFavoriteTransition,S=y.runDeleteTransition;return f.createElement(f.Fragment,null,f.createElement("div",{className:"DocSearch-Hit-action"},f.createElement("button",{className:"DocSearch-Hit-action-button",title:s,type:"submit",onClick:function(O){O.preventDefault(),O.stopPropagation(),_(function(){r.favoriteSearches.add(b),r.recentSearches.remove(b),r.refresh()})}},f.createElement(Gn,null))),f.createElement("div",{className:"DocSearch-Hit-action"},f.createElement("button",{className:"DocSearch-Hit-action-button",title:m,type:"submit",onClick:function(O){O.preventDefault(),O.stopPropagation(),S(function(){r.recentSearches.remove(b),r.refresh()})}},f.createElement(Kt,null))))}})),f.createElement(zt,ht({},r,{title:v,collection:r.state.collections[1],renderIcon:function(){return f.createElement("div",{className:"DocSearch-Hit-icon"},f.createElement(Gn,null))},renderAction:function(y){var b=y.item,_=y.runDeleteTransition;return f.createElement("div",{className:"DocSearch-Hit-action"},f.createElement("button",{className:"DocSearch-Hit-action-button",title:h,type:"submit",onClick:function(S){S.preventDefault(),S.stopPropagation(),_(function(){r.favoriteSearches.remove(b),r.refresh()})}},f.createElement(Kt,null)))}})))}var ea=["translations"];function yt(){return yt=Object.assign||function(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}var na=f.memo(function(t){var e=t.translations,n=e===void 0?{}:e,r=ta(t,ea);if(r.state.status==="error")return f.createElement(Hi,{translations:n==null?void 0:n.errorScreen});var o=r.state.collections.some(function(i){return i.items.length>0});return r.state.query?o===!1?f.createElement(Vi,yt({},r,{translations:n==null?void 0:n.noResultsScreen})):f.createElement(Zi,r):f.createElement(Xi,yt({},r,{hasCollections:o,translations:n==null?void 0:n.startScreen}))},function(t,e){return e.state.status==="loading"||e.state.status==="stalled"}),ra=["translations"];function gt(){return gt=Object.assign||function(t){for(var e=1;e=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function ia(t){var e=t.translations,n=e===void 0?{}:e,r=oa(t,ra),o=n.resetButtonTitle,i=o===void 0?"Clear the query":o,a=n.resetButtonAriaLabel,u=a===void 0?"Clear the query":a,c=n.cancelButtonText,s=c===void 0?"Cancel":c,l=n.cancelButtonAriaLabel,m=l===void 0?"Cancel":l,p=r.getFormProps({inputElement:r.inputRef.current}).onReset;return f.useEffect(function(){r.autoFocus&&r.inputRef.current&&r.inputRef.current.focus()},[r.autoFocus,r.inputRef]),f.useEffect(function(){r.isFromSelection&&r.inputRef.current&&r.inputRef.current.select()},[r.isFromSelection,r.inputRef]),f.createElement(f.Fragment,null,f.createElement("form",{className:"DocSearch-Form",onSubmit:function(v){v.preventDefault()},onReset:p},f.createElement("label",gt({className:"DocSearch-MagnifierLabel"},r.getLabelProps()),f.createElement(Hr,null)),f.createElement("div",{className:"DocSearch-LoadingIndicator"},f.createElement(Ai,null)),f.createElement("input",gt({className:"DocSearch-Input",ref:r.inputRef},r.getInputProps({inputElement:r.inputRef.current,autoFocus:r.autoFocus,maxLength:64}))),f.createElement("button",{type:"reset",title:i,className:"DocSearch-Reset","aria-label":u,hidden:!r.state.query},f.createElement(Kt,null))),f.createElement("button",{className:"DocSearch-Cancel",type:"reset","aria-label":m,onClick:r.onClose},s))}var aa=["_highlightResult","_snippetResult"];function ca(t,e){if(t==null)return{};var n,r,o=function(a,u){if(a==null)return{};var c,s,l={},m=Object.keys(a);for(s=0;s=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function ua(t){return function(){var e="__TEST_KEY__";try{return localStorage.setItem(e,""),localStorage.removeItem(e),!0}catch{return!1}}()===!1?{setItem:function(){},getItem:function(){return[]}}:{setItem:function(e){return window.localStorage.setItem(t,JSON.stringify(e))},getItem:function(){var e=window.localStorage.getItem(t);return e?JSON.parse(e):[]}}}function ar(t){var e=t.key,n=t.limit,r=n===void 0?5:n,o=ua(e),i=o.getItem().slice(0,r);return{add:function(a){var u=a,c=(u._highlightResult,u._snippetResult,ca(u,aa)),s=i.findIndex(function(l){return l.objectID===c.objectID});s>-1&&i.splice(s,1),i.unshift(c),i=i.slice(0,r),o.setItem(i)},remove:function(a){i=i.filter(function(u){return u.objectID!==a.objectID}),o.setItem(i)},getAll:function(){return i}}}var la=["facetName","facetQuery"];function sa(t){var e,n="algoliasearch-client-js-".concat(t.key),r=function(){return e===void 0&&(e=t.localStorage||window.localStorage),e},o=function(){return JSON.parse(r().getItem(n)||"{}")};return{get:function(i,a){var u=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then(function(){var c=JSON.stringify(i),s=o()[c];return Promise.all([s||a(),s!==void 0])}).then(function(c){var s=st(c,2),l=s[0],m=s[1];return Promise.all([l,m||u.miss(l)])}).then(function(c){return st(c,1)[0]})},set:function(i,a){return Promise.resolve().then(function(){var u=o();return u[JSON.stringify(i)]=a,r().setItem(n,JSON.stringify(u)),a})},delete:function(i){return Promise.resolve().then(function(){var a=o();delete a[JSON.stringify(i)],r().setItem(n,JSON.stringify(a))})},clear:function(){return Promise.resolve().then(function(){r().removeItem(n)})}}}function we(t){var e=ft(t.caches),n=e.shift();return n===void 0?{get:function(r,o){var i=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{miss:function(){return Promise.resolve()}};return o().then(function(a){return Promise.all([a,i.miss(a)])}).then(function(a){return st(a,1)[0]})},set:function(r,o){return Promise.resolve(o)},delete:function(r){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(r,o){var i=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{miss:function(){return Promise.resolve()}};return n.get(r,o,i).catch(function(){return we({caches:e}).get(r,o,i)})},set:function(r,o){return n.set(r,o).catch(function(){return we({caches:e}).set(r,o)})},delete:function(r){return n.delete(r).catch(function(){return we({caches:e}).delete(r)})},clear:function(){return n.clear().catch(function(){return we({caches:e}).clear()})}}}function Tt(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{serializable:!0},e={};return{get:function(n,r){var o=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{miss:function(){return Promise.resolve()}},i=JSON.stringify(n);if(i in e)return Promise.resolve(t.serializable?JSON.parse(e[i]):e[i]);var a=r(),u=o&&o.miss||function(){return Promise.resolve()};return a.then(function(c){return u(c)}).then(function(){return a})},set:function(n,r){return e[JSON.stringify(n)]=t.serializable?JSON.stringify(r):r,Promise.resolve(r)},delete:function(n){return delete e[JSON.stringify(n)],Promise.resolve()},clear:function(){return e={},Promise.resolve()}}}function fa(t){for(var e=t.length-1;e>0;e--){var n=Math.floor(Math.random()*(e+1)),r=t[e];t[e]=t[n],t[n]=r}return t}function $r(t,e){return e&&Object.keys(e).forEach(function(n){t[n]=e[n](t)}),t}function bt(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),r=1;r0?r:void 0,timeout:n.timeout||e,headers:n.headers||{},queryParameters:n.queryParameters||{},cacheable:n.cacheable}}var me={Read:1,Write:2,Any:3},Qr=1,ma=2,Zr=3;function Yr(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:Qr;return I(I({},t),{},{status:e,lastUpdate:Date.now()})}function Gr(t){return typeof t=="string"?{protocol:"https",url:t,accept:me.Any}:{protocol:t.protocol||"https",url:t.url,accept:t.accept||me.Any}}var ur="GET",_t="POST";function pa(t,e){return Promise.all(e.map(function(n){return t.get(n,function(){return Promise.resolve(Yr(n))})})).then(function(n){var r=n.filter(function(a){return function(u){return u.status===Qr||Date.now()-u.lastUpdate>12e4}(a)}),o=n.filter(function(a){return function(u){return u.status===Zr&&Date.now()-u.lastUpdate<=12e4}(a)}),i=[].concat(ft(r),ft(o));return{getTimeout:function(a,u){return(o.length===0&&a===0?1:o.length+3+a)*u},statelessHosts:i.length>0?i.map(function(a){return Gr(a)}):e}})}function lr(t,e,n,r){var o=[],i=function(p,v){if(!(p.method===ur||p.data===void 0&&v.data===void 0)){var d=Array.isArray(p.data)?p.data:I(I({},p.data),v.data);return JSON.stringify(d)}}(n,r),a=function(p,v){var d=I(I({},p.headers),v.headers),h={};return Object.keys(d).forEach(function(y){var b=d[y];h[y.toLowerCase()]=b}),h}(t,r),u=n.method,c=n.method!==ur?{}:I(I({},n.data),r.data),s=I(I(I({"x-algolia-agent":t.userAgent.value},t.queryParameters),c),r.queryParameters),l=0,m=function p(v,d){var h=v.pop();if(h===void 0)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:sr(o)};var y={data:i,headers:a,method:u,url:da(h,n.path,s),connectTimeout:d(l,t.timeouts.connect),responseTimeout:d(l,r.timeout)},b=function(S){var O={request:y,response:S,host:h,triesLeft:v.length};return o.push(O),O},_={onSucess:function(S){return function(O){try{return JSON.parse(O.content)}catch(g){throw function(P,C){return{name:"DeserializationError",message:P,response:C}}(g.message,O)}}(S)},onRetry:function(S){var O=b(S);return S.isTimedOut&&l++,Promise.all([t.logger.info("Retryable failure",eo(O)),t.hostsCache.set(h,Yr(h,S.isTimedOut?Zr:ma))]).then(function(){return p(v,d)})},onFail:function(S){throw b(S),function(O,g){var P=O.content,C=O.status,L=P;try{L=JSON.parse(P).message}catch{}return function(x,k,N){return{name:"ApiError",message:x,status:k,transporterStackTrace:N}}(L,C,g)}(S,sr(o))}};return t.requester.send(y).then(function(S){return function(O,g){return function(P){var C=P.status;return P.isTimedOut||function(L){var x=L.isTimedOut,k=L.status;return!x&&~~k==0}(P)||~~(C/100)!=2&&~~(C/100)!=4}(O)?g.onRetry(O):~~(O.status/100)==2?g.onSucess(O):g.onFail(O)}(S,_)})};return pa(t.hostsCache,e).then(function(p){return m(ft(p.statelessHosts).reverse(),p.getTimeout)})}function va(t){var e={value:"Algolia for JavaScript (".concat(t,")"),add:function(n){var r="; ".concat(n.segment).concat(n.version!==void 0?" (".concat(n.version,")"):"");return e.value.indexOf(r)===-1&&(e.value="".concat(e.value).concat(r)),e}};return e}function da(t,e,n){var r=Xr(n),o="".concat(t.protocol,"://").concat(t.url,"/").concat(e.charAt(0)==="/"?e.substr(1):e);return r.length&&(o+="?".concat(r)),o}function Xr(t){return Object.keys(t).map(function(e){return bt("%s=%s",e,(n=t[e],Object.prototype.toString.call(n)==="[object Object]"||Object.prototype.toString.call(n)==="[object Array]"?JSON.stringify(t[e]):t[e]));var n}).join("&")}function sr(t){return t.map(function(e){return eo(e)})}function eo(t){var e=t.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return I(I({},t),{},{request:I(I({},t.request),{},{headers:I(I({},t.request.headers),e)})})}var ha=function(t){var e=t.appId,n=function(i,a,u){var c={"x-algolia-api-key":u,"x-algolia-application-id":a};return{headers:function(){return i===lt.WithinHeaders?c:{}},queryParameters:function(){return i===lt.WithinQueryParameters?c:{}}}}(t.authMode!==void 0?t.authMode:lt.WithinHeaders,e,t.apiKey),r=function(i){var a=i.hostsCache,u=i.logger,c=i.requester,s=i.requestsCache,l=i.responsesCache,m=i.timeouts,p=i.userAgent,v=i.hosts,d=i.queryParameters,h={hostsCache:a,logger:u,requester:c,requestsCache:s,responsesCache:l,timeouts:m,userAgent:p,headers:i.headers,queryParameters:d,hosts:v.map(function(y){return Gr(y)}),read:function(y,b){var _=cr(b,h.timeouts.read),S=function(){return lr(h,h.hosts.filter(function(g){return(g.accept&me.Read)!=0}),y,_)};if((_.cacheable!==void 0?_.cacheable:y.cacheable)!==!0)return S();var O={request:y,mappedRequestOptions:_,transporter:{queryParameters:h.queryParameters,headers:h.headers}};return h.responsesCache.get(O,function(){return h.requestsCache.get(O,function(){return h.requestsCache.set(O,S()).then(function(g){return Promise.all([h.requestsCache.delete(O),g])},function(g){return Promise.all([h.requestsCache.delete(O),Promise.reject(g)])}).then(function(g){var P=st(g,2);return P[0],P[1]})})},{miss:function(g){return h.responsesCache.set(O,g)}})},write:function(y,b){return lr(h,h.hosts.filter(function(_){return(_.accept&me.Write)!=0}),y,cr(b,h.timeouts.write))}};return h}(I(I({hosts:[{url:"".concat(e,"-dsn.algolia.net"),accept:me.Read},{url:"".concat(e,".algolia.net"),accept:me.Write}].concat(fa([{url:"".concat(e,"-1.algolianet.com")},{url:"".concat(e,"-2.algolianet.com")},{url:"".concat(e,"-3.algolianet.com")}]))},t),{},{headers:I(I(I({},n.headers()),{"content-type":"application/x-www-form-urlencoded"}),t.headers),queryParameters:I(I({},n.queryParameters()),t.queryParameters)})),o={transporter:r,appId:e,addAlgoliaAgent:function(i,a){r.userAgent.add({segment:i,version:a})},clearCache:function(){return Promise.all([r.requestsCache.clear(),r.responsesCache.clear()]).then(function(){})}};return $r(o,t.methods)},to=function(t){return function(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r={transporter:t.transporter,appId:t.appId,indexName:e};return $r(r,n.methods)}},fr=function(t){return function(e,n){var r=e.map(function(o){return I(I({},o),{},{params:Xr(o.params||{})})});return t.transporter.read({method:_t,path:"1/indexes/*/queries",data:{requests:r},cacheable:!0},n)}},mr=function(t){return function(e,n){return Promise.all(e.map(function(r){var o=r.params,i=o.facetName,a=o.facetQuery,u=mo(o,la);return to(t)(r.indexName,{methods:{searchForFacetValues:no}}).searchForFacetValues(i,a,I(I({},n),u))}))}},ya=function(t){return function(e,n,r){return t.transporter.read({method:_t,path:bt("1/answers/%s/prediction",t.indexName),data:{query:e,queryLanguages:n},cacheable:!0},r)}},ga=function(t){return function(e,n){return t.transporter.read({method:_t,path:bt("1/indexes/%s/query",t.indexName),data:{query:e},cacheable:!0},n)}},no=function(t){return function(e,n,r){return t.transporter.read({method:_t,path:bt("1/indexes/%s/facets/%s/query",t.indexName,e),data:{facetQuery:n},cacheable:!0},r)}},ba=1,_a=2,Oa=3;function ro(t,e,n){var r,o={appId:t,apiKey:e,timeouts:{connect:1,read:2,write:30},requester:{send:function(i){return new Promise(function(a){var u=new XMLHttpRequest;u.open(i.method,i.url,!0),Object.keys(i.headers).forEach(function(m){return u.setRequestHeader(m,i.headers[m])});var c,s=function(m,p){return setTimeout(function(){u.abort(),a({status:0,content:p,isTimedOut:!0})},1e3*m)},l=s(i.connectTimeout,"Connection timeout");u.onreadystatechange=function(){u.readyState>u.OPENED&&c===void 0&&(clearTimeout(l),c=s(i.responseTimeout,"Socket timeout"))},u.onerror=function(){u.status===0&&(clearTimeout(l),clearTimeout(c),a({content:u.responseText||"Network request failed",status:u.status,isTimedOut:!1}))},u.onload=function(){clearTimeout(l),clearTimeout(c),a({content:u.responseText,status:u.status,isTimedOut:!1})},u.send(i.data)})}},logger:(r=Oa,{debug:function(i,a){return ba>=r&&console.debug(i,a),Promise.resolve()},info:function(i,a){return _a>=r&&console.info(i,a),Promise.resolve()},error:function(i,a){return console.error(i,a),Promise.resolve()}}),responsesCache:Tt(),requestsCache:Tt({serializable:!1}),hostsCache:we({caches:[sa({key:"".concat("4.8.5","-").concat(t)}),Tt()]}),userAgent:va("4.8.5").add({segment:"Browser",version:"lite"}),authMode:lt.WithinQueryParameters};return ha(I(I(I({},o),n),{},{methods:{search:fr,searchForFacetValues:mr,multipleQueries:fr,multipleSearchForFacetValues:mr,initIndex:function(i){return function(a){return to(i)(a,{methods:{search:ga,searchForFacetValues:no,findAnswers:ya}})}}}}))}ro.version="4.8.5";var Sa=["footer","searchBox"];function Fe(){return Fe=Object.assign||function(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n=0||(l[c]=a[c]);return l}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n])}return o}function Pa(t){var e=t.appId,n=t.apiKey,r=t.indexName,o=t.placeholder,i=o===void 0?"Search docs":o,a=t.searchParameters,u=t.maxResultsPerGroup,c=t.onClose,s=c===void 0?$i:c,l=t.transformItems,m=l===void 0?ir:l,p=t.hitComponent,v=p===void 0?ki:p,d=t.resultsFooterComponent,h=d===void 0?function(){return null}:d,y=t.navigator,b=t.initialScrollY,_=b===void 0?0:b,S=t.transformSearchClient,O=S===void 0?ir:S,g=t.disableUserPersonalization,P=g!==void 0&&g,C=t.initialQuery,L=C===void 0?"":C,x=t.translations,k=x===void 0?{}:x,N=t.getMissingResultsUrl,U=t.insights,F=U!==void 0&&U,M=k.footer,Ot=k.searchBox,St=Ea(k,Sa),Je=wa(f.useState({query:"",collections:[],completion:null,context:{},isOpen:!1,activeItemId:null,status:"idle"}),2),B=Je[0],oo=Je[1],Gt=f.useRef(null),jt=f.useRef(null),Xt=f.useRef(null),$e=f.useRef(null),he=f.useRef(null),Q=f.useRef(10),en=f.useRef(typeof window<"u"?window.getSelection().toString().slice(0,64):"").current,ee=f.useRef(L||en).current,tn=function(w,D,T){return f.useMemo(function(){var H=ro(w,D);return H.addAlgoliaAgent("docsearch","3.5.1"),/docsearch.js \(.*\)/.test(H.transporter.userAgent.value)===!1&&H.addAlgoliaAgent("docsearch-react","3.5.1"),T(H)},[w,D,T])}(e,n,O),oe=f.useRef(ar({key:"__DOCSEARCH_FAVORITE_SEARCHES__".concat(r),limit:10})).current,ye=f.useRef(ar({key:"__DOCSEARCH_RECENT_SEARCHES__".concat(r),limit:oe.getAll().length===0?7:4})).current,ge=f.useCallback(function(w){if(!P){var D=w.type==="content"?w.__docsearch_parent:w;D&&oe.getAll().findIndex(function(T){return T.objectID===D.objectID})===-1&&ye.add(D)}},[oe,ye,P]),io=f.useCallback(function(w){if(B.context.algoliaInsightsPlugin&&w.__autocomplete_id){var D=w,T={eventName:"Item Selected",index:D.__autocomplete_indexName,items:[D],positions:[w.__autocomplete_id],queryID:D.__autocomplete_queryID};B.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(T)}},[B.context.algoliaInsightsPlugin]),be=f.useMemo(function(){return Pi({id:"docsearch",defaultActiveItemId:0,placeholder:i,openOnFocus:!0,initialState:{query:ee,context:{searchSuggestions:[]}},insights:F,navigator:y,onStateChange:function(w){oo(w.state)},getSources:function(w){var D=w.query,T=w.state,H=w.setContext,Z=w.setStatus;if(!D)return P?[]:[{sourceId:"recentSearches",onSelect:function(A){var K=A.item,ie=A.event;ge(K),it(ie)||s()},getItemUrl:function(A){return A.item.url},getItems:function(){return ye.getAll()}},{sourceId:"favoriteSearches",onSelect:function(A){var K=A.item,ie=A.event;ge(K),it(ie)||s()},getItemUrl:function(A){return A.item.url},getItems:function(){return oe.getAll()}}];var Y=!!F;return tn.search([{query:D,indexName:r,params:Rt({attributesToRetrieve:["hierarchy.lvl0","hierarchy.lvl1","hierarchy.lvl2","hierarchy.lvl3","hierarchy.lvl4","hierarchy.lvl5","hierarchy.lvl6","content","type","url"],attributesToSnippet:["hierarchy.lvl1:".concat(Q.current),"hierarchy.lvl2:".concat(Q.current),"hierarchy.lvl3:".concat(Q.current),"hierarchy.lvl4:".concat(Q.current),"hierarchy.lvl5:".concat(Q.current),"hierarchy.lvl6:".concat(Q.current),"content:".concat(Q.current)],snippetEllipsisText:"…",highlightPreTag:"",highlightPostTag:"",hitsPerPage:20,clickAnalytics:Y},a)}]).catch(function(A){throw A.name==="RetryError"&&Z("error"),A}).then(function(A){var K=A.results,ie=K[0],uo=ie.hits,lo=ie.nbHits,wt=or(uo,function(Et){return Jr(Et)},u);T.context.searchSuggestions.length0&&(nn(),he.current&&he.current.focus())},[ee,nn]),f.useEffect(function(){function w(){if(jt.current){var D=.01*window.innerHeight;jt.current.style.setProperty("--docsearch-vh","".concat(D,"px"))}}return w(),window.addEventListener("resize",w),function(){window.removeEventListener("resize",w)}},[]),f.createElement("div",Fe({ref:Gt},co({"aria-expanded":!0}),{className:["DocSearch","DocSearch-Container",B.status==="stalled"&&"DocSearch-Container--Stalled",B.status==="error"&&"DocSearch-Container--Errored"].filter(Boolean).join(" "),role:"button",tabIndex:0,onMouseDown:function(w){w.target===w.currentTarget&&s()}}),f.createElement("div",{className:"DocSearch-Modal",ref:jt},f.createElement("header",{className:"DocSearch-SearchBar",ref:Xt},f.createElement(ia,Fe({},be,{state:B,autoFocus:ee.length===0,inputRef:he,isFromSelection:!!ee&&ee===en,translations:Ot,onClose:s}))),f.createElement("div",{className:"DocSearch-Dropdown",ref:$e},f.createElement(na,Fe({},be,{indexName:r,state:B,hitComponent:v,resultsFooterComponent:h,disableUserPersonalization:P,recentSearches:ye,favoriteSearches:oe,inputRef:he,translations:St,getMissingResultsUrl:N,onItemClick:function(w,D){io(w),ge(w),it(D)||s()}}))),f.createElement("footer",{className:"DocSearch-Footer"},f.createElement(Di,{translations:M}))))}function $t(){return $t=Object.assign||function(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,r=new Array(e);n1&&arguments[1]!==void 0?arguments[1]:window;return typeof e=="string"?n.document.querySelector(e):e}(t.container,t.environment))}export{Da as default}; diff --git a/preview/assets/index.html-06effaf7.js b/preview/assets/index.html-06effaf7.js new file mode 100644 index 000000000..6ce67aab5 --- /dev/null +++ b/preview/assets/index.html-06effaf7.js @@ -0,0 +1,6 @@ +import{_ as a,r as s,o as t,c as i,a as o,b as n,d as c,w as l,e as r}from"./app-73097456.js";const d={},u=r(`

Quick Start

For the impatient, here's how to get a boilerplate NotifyBC instance up and running if you have git and node.js installed:

git clone https://github.com/bcgov/NotifyBC.git
+cd NotifyBC
+npm i && npm run build
+npm run start
+# => Now browse to http://localhost:3000
+
`,3);function p(m,h){const e=s("RouterLink");return t(),i("div",null,[u,o("p",null,[n("If you're running into problems, check out full "),c(e,{to:"/docs/installation/"},{default:l(()=>[n("installation")]),_:1}),n(" guide.")])])}const v=a(d,[["render",p],["__file","index.html.vue"]]);export{v as default}; diff --git a/preview/assets/index.html-09f857ca.js b/preview/assets/index.html-09f857ca.js new file mode 100644 index 000000000..f95252f37 --- /dev/null +++ b/preview/assets/index.html-09f857ca.js @@ -0,0 +1 @@ +const i=JSON.parse('{"key":"v-563ef996","path":"/docs/api-notification/","title":"Notification","lang":"en-US","frontmatter":{"permalink":"/docs/api-notification/"},"headers":[{"level":2,"title":"Model Schema","slug":"model-schema","link":"#model-schema","children":[]},{"level":2,"title":"Get Notifications","slug":"get-notifications","link":"#get-notifications","children":[]},{"level":2,"title":"Get Notification Count","slug":"get-notification-count","link":"#get-notification-count","children":[]},{"level":2,"title":"Create/Send Notifications","slug":"create-send-notifications","link":"#create-send-notifications","children":[]},{"level":2,"title":"Update a Notification","slug":"update-a-notification","link":"#update-a-notification","children":[]},{"level":2,"title":"Delete a Notification","slug":"delete-a-notification","link":"#delete-a-notification","children":[]},{"level":2,"title":"Replace a Notification","slug":"replace-a-notification","link":"#replace-a-notification","children":[]}],"git":{},"filePathRelative":"docs/api/notification.md"}');export{i as data}; diff --git a/preview/assets/index.html-15fde261.js b/preview/assets/index.html-15fde261.js new file mode 100644 index 000000000..1e7f51523 --- /dev/null +++ b/preview/assets/index.html-15fde261.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-eb8745ce","path":"/docs/api-bounce/","title":"Bounce","lang":"en-US","frontmatter":{"permalink":"/docs/api-bounce/","next":"/docs/benchmarks/"},"headers":[{"level":2,"title":"Model Schema","slug":"model-schema","link":"#model-schema","children":[]}],"git":{},"filePathRelative":"docs/api/bounce.md"}');export{e as data}; diff --git a/preview/assets/index.html-1ae75d86.js b/preview/assets/index.html-1ae75d86.js new file mode 100644 index 000000000..10e05d974 --- /dev/null +++ b/preview/assets/index.html-1ae75d86.js @@ -0,0 +1,63 @@ +import{_ as p,r as i,o as l,c as r,a as s,b as n,d as e,w as o,e as c}from"./app-73097456.js";const d={},u=s("h1",{id:"cron-jobs",tabindex:"-1"},[s("a",{class:"header-anchor",href:"#cron-jobs","aria-hidden":"true"},"#"),n(" Cron Jobs")],-1),m=s("p",null,[s("em",null,"NotifyBC"),n(" runs several cron jobs described below. These jobs are controlled by sub-properties defined in config object "),s("em",null,"cron"),n(". To change config, create the object and properties in file "),s("em",null,"/src/config.local.js"),n(".")],-1),v=s("a",{name:"timeSpec"},null,-1),f=s("em",null,"timeSpec",-1),k={href:"https://www.freebsd.org/cgi/man.cgi?crontab(5)",target:"_blank",rel:"noopener noreferrer"},b={href:"https://github.com/kelektiv/node-cron#cron-ranges",target:"_blank",rel:"noopener noreferrer"},h=c(`

Purge Data

This cron job purges old notifications, subscriptions and notification bounces. The default frequency of cron job and retention policy are defined by cron.purgeData config object in file /src/config.ts

module.exports = {
+  cron: {
+    purgeData: {
+      // daily at 1am
+      timeSpec: '0 0 1 * * *',
+      pushNotificationRetentionDays: 30,
+      expiredInAppNotificationRetentionDays: 30,
+      nonConfirmedSubscriptionRetentionDays: 30,
+      deletedBounceRetentionDays: 30,
+      expiredAccessTokenRetentionDays: 30,
+      defaultRetentionDays: 30,
+    },
+  },
+};
+

where

  • pushNotificationRetentionDays: the retention days of push notifications
  • expiredInAppNotificationRetentionDays: the retention days of expired inApp notifications
  • nonConfirmedSubscriptionRetentionDays: the retention days of non-confirmed subscriptions, i.e. all unconfirmed and deleted subscriptions
  • deletedBounceRetentionDays: the retention days of deleted notification bounces
  • expiredAccessTokenRetentionDays: the retention days of expired access tokens
  • defaultRetentionDays: if any of the above retention day config item is omitted, default retention days is used as fall back.

To change a config item, set the config item in file /src/config.local.js. For example, to run cron jobs at 2am daily, add following object to /src/config.local.js

module.exports = {
+  cron: {
+    purgeData: {
+      timeSpec: '0 0 2 * * *',
+    },
+  },
+};
+

Dispatch Live Notifications

This cron job sends out future-dated notifications when the notification becomes current. The default config is defined by cron.dispatchLiveNotifications config object in file /src/config.ts

module.exports = {
+  cron: {
+    dispatchLiveNotifications: {
+      // minutely
+      timeSpec: '0 * * * * *',
+    },
+  },
+};
+

Check Rss Config Updates

This cron job monitors RSS feed notification dynamic config items. If a config item is created, updated or deleted, the cron job starts, restarts, or stops the RSS-specific cron job. The default config is defined by cron.checkRssConfigUpdates config object in file /src/config.ts

module.exports = {
+  cron: {
+    checkRssConfigUpdates: {
+      // minutely
+      timeSpec: '0 * * * * *',
+    },
+  },
+};
+

Note timeSpec doesn't control the RSS poll frequency (which is defined in dynamic configs and is service specific), instead it only controls the frequency to check for dynamic config changes.

Delete Notification Bounces

This cron job deletes notification bounces if the latest notification is deemed delivered successfully. The criteria of successful delivery are

  1. No bounce received since the latest notification started dispatching, and
  2. a configured time span has lapsed since the latest notification finished dispatching

The default config is defined by cron.deleteBounces config object in file /src/config.ts

module.exports = {
+  cron: {
+    deleteBounces: {
+      // hourly
+      timeSpec: '0 0 * * * *',
+      minLapsedHoursSinceLatestNotificationEnded: 1,
+    },
+  },
+};
+

where

  • minLapsedHoursSinceLatestNotificationEnded is the time span

Re-dispatch Broadcast Push Notifications

`,22),g=c(`

The default config is defined by cron.reDispatchBroadcastPushNotifications config object in file /src/config.ts

module.exports = {
+  cron: {
+    reDispatchBroadcastPushNotifications: {
+      // minutely
+      timeSpec: '0 * * * * *',
+    },
+  },
+};
+

Clear Redis Datastore

This cron job clears Redis datastore used for SMS and email throttle. The job is enabled only if Redis is used. Datastore is cleared only when there is no broadcast push notifications in sending state. Without this cron job, updated throttle settings in config file will never take effect, and staled jobs in Redis datastore will not be cleaned up.

The default config is defined by cron.clearRedisDatastore config object in file /src/config.ts

module.exports = {
+  cron: {
+    clearRedisDatastore: {
+      // hourly
+      timeSpec: '0 0 * * * *',
+    },
+  },
+};
+
`,6);function y(x,_){const a=i("RouterLink"),t=i("ExternalLinkIcon");return l(),r("div",null,[u,m,s("p",null,[n("By default cron jobs are enabled. In a multi-node deployment, cron jobs should only run on the "),e(a,{to:"/docs/config-nodeRoles/"},{default:o(()=>[n("master node")]),_:1}),n(" to ensure single execution.")]),s("p",null,[n("All cron jobs have a property named "),v,f,n(" with the value of a space separated fields conforming to "),s("a",k,[n("unix crontab format"),e(t)]),n(" with an optional left-most seconds field. See "),s("a",b,[n("allowed ranges"),e(t)]),n(" of each field.")]),h,s("p",null,[n("This cron job re-dispatches a broadcast push notifications when original request failed. It is part of "),e(a,{to:"/docs/config/notification.html#guaranteed-broadcast-push-dispatch-processing"},{default:o(()=>[n("guaranteed broadcast push dispatch processing")]),_:1})]),g])}const R=p(d,[["render",y],["__file","index.html.vue"]]);export{R as default}; diff --git a/preview/assets/index.html-1d68b11c.js b/preview/assets/index.html-1d68b11c.js new file mode 100644 index 000000000..e76384637 --- /dev/null +++ b/preview/assets/index.html-1d68b11c.js @@ -0,0 +1,110 @@ +import{_ as l,r,o,c as d,a as e,b as s,d as a,e as t}from"./app-73097456.js";const p={},u=t(`

Administrator

The administrator API provides knowledge factor authentication to identify admin request by access token (aka API token in other literatures) associated with a registered administrator maintained in NotifyBC database. Because knowledge factor authentication is vulnerable to brute-force attack, administrator API based access token authentication is less favorable than admin ip list, client certificate, or OIDC authentication.

Avoid Administrator API

Administrator API was created to circumvent an OpenShift limitation - the source ip of a request initiated from an OpenShift pod cannot be exclusively allocated to the pod's project, rather it has to be shared by all OpenShift projects. Therefore it's difficult to impose granular access control based on source ip.

With the introduction client certificate in v2.4.0, most use cases, if not all, that need Administrator API including the OpenShift use case mentioned above can be addressed by client certificate. Therefore only use Administrator API sparingly as last resort.

To enable access token authentication,

  1. a super-admin signs up an administrator

    For example,

    curl -X POST "http://localhost:3000/api/administrators" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{\\"username\\":\\"Foo\\",\\"email\\":\\"user@example.com\\",\\"password\\":\\"secret\\"}"
    +

    The step can also be completed in web console using add button in Administrators panel.

  2. Either super-admin or the user login to generate an access token

    For example,

    curl -X POST "http://localhost:3000/api/administrators/login" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{\\"email\\":\\"user@example.com\\",\\"password\\":\\"secret\\",\\"tokenName\\":\\"myApp\\"}"
    +

    The step can also be completed in web console GUI by an anonymous user using login button at top right corner. Access token generated by GUI is valid for 12hrs.

  3. Apply access token to either Authorization header or access_token query parameter to make authenticated requests. For example, to get a list of notifications

    ACCESS_TOKEN=6Nb2ti5QEXIoDBS5FQGWIz4poRFiBCMMYJbYXSGHWuulOuy0GTEuGx2VCEVvbpBK
    +
    +# Authorization Header
    +curl -X GET -H "Authorization: $ACCESS_TOKEN" \\
    +http://localhost:3000/api/notifications
    +
    +# Query Parameter
    +curl -X GET http://localhost:3000/api/notifications?access_token=$ACCESS_TOKEN
    +

    In web console, once login as administrator, the access token is automatically applied.

Model Schemas

The Administrator API operates on three related sub-models - Administrator, UserCredential and AccessToken. An administrator has one and only one user credential and zero or more access tokens. Their relationship is diagramed as

`,7),c=["src"],m=t(`

Administrator

NameAttributes

id

typestring, format depends on db
auto-generatedtrue

email

typestring
requiredtrue
uniquetrue

username

user name

typestring
requiredfalse

UserCredential

NameAttributes

id

typestring, format depends on db
auto-generatedtrue

password

hashed password

typestring
requiredtrue

userId

foreign key to Administrator model

typestring
requiredtrue

AccessToken

NameAttributes

id

64-byte random alphanumeric characters

typestring
auto-generatedtrue

userId

foreign key to Administrator model

typestring
requiredtrue

ttl

Time-to-live in seconds. If absent, access token never expires.

typenumber
requiredfalse

name

Name of the access token. Can be used to identify applications that use the token.

typestring
requiredfalse

Sign Up

POST /administrators
+

This API allows a super-admin to create an admin.

  • permissions required, one of

    • super admin
  • inputs

    • user information

      {
      +  "email": "string",
      +  "password": "string",
      +  "username": "string"
      +}
      +

      Password must meet following complexity rules:

      • contains at least 10 characters
      • contains at least one lower case character a-z
      • contains at least one upper case character A-Z
      • contains at least one numeric character 0-9
      • contains at lease one special character in !_@#$&*

      email must be unique. username is optional.

      • required: true
      • parameter type: request body
      • data type: object
  • outcome

    • for super-admin requests,
      • an Administrator is generated, populated with email and username
      • a UserCredential is generated, populated with hashed password
      • Administrator is returned
    • forbidden otherwise

Login

POST /administrators/login
+

This API allows an admin to login and create an access token

  • inputs

    • user information

      {
      +  "email": "user@example.com",
      +  "password": "string",
      +  "tokenName": "string",
      +  "ttl": 0
      +}
      +

      tokenName and ttl are optional. If ttl is absent, access token never expires.

      • required: true
      • parameter type: request body
      • data type: object
  • outcome

    • if login is successful
      • a new AccessToken is generated with tokenName is saved to AccessToken.name and ttl is saved to AccessToken.ttl.
      • the new access token is returned
        {
        +  "token": "string"
        +}
        +
    • forbidden otherwise

Set Password

POST /administrators/{id}/user-credential
+

This API allows a super-admin or admin to create or update password by id. An admin can only create/update own record.

  • permissions required, one of

    • super admin
    • admin
  • inputs

    • Administrator id

      • required: true
      • parameter type: path
      • data type: string
    • password

      {
      +  "password": "string"
      +}
      +

      The password must meet complexity rules specified in Sign Up.

      • required: true
      • parameter type: request body
      • data type: object
  • outcome
    • for super-admins or admin,
      1. hash the input password
      2. remove any existing UserCredential.password for the Administrator
      3. create a new UserCredential.password
    • forbidden otherwise

Get Administrators

GET /administrators
+

This API allows a super-admin or admin to search for administrators. An admin can only search for own record

`,22),h=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),v=e("p",null,"inputs",-1),b=t("

a filter containing properties where, fields, order, skip, and limit

  • parameter name: filter
  • required: false
  • parameter type: query
  • data type: object

The filter can be expressed as either

",3),k=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),g={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},q=e("code",null,'?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"',-1),f=t(`

Regardless, the filter will have to be parsed into a JSON object conforming to

{
+    "where": {...},
+    "fields": ...,
+    "order": ...,
+    "skip": ...,
+    "limit": ...,
+}
+

All properties are optional. The syntax for each property is documented, respectively

`,3),y=e("em",null,"where",-1),_={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},w=e("em",null,"fields",-1),x={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.select()",target:"_blank",rel:"noopener noreferrer"},A=e("em",null,"order",-1),j={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},T=e("em",null,"skip",-1),P={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.skip/",target:"_blank",rel:"noopener noreferrer"},I=e("em",null,"limit",-1),S={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.limit/",target:"_blank",rel:"noopener noreferrer"},D=t(`
  • outcome

    • for super-admins, returns an array of Administrators matching the filter
    • for admins, returns an array of one element - own record if the record matches the filter; empty array otherwise
    • forbidden otherwise
  • example

    to retrieve administrators created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
    +

    the value of the filter query parameter is URL-encoded stringified JSON object

    {
    +  "where": {
    +    "created": {
    +      "$gte": "2023-01-01",
    +      "$lt": "2024-01-01"
    +    }
    +  }
    +}
    +
  • `,2),C=t(`

    Get Administrator Count

    GET /administrators/count
    +

    This API allows a super-admin or admin to count administrators by filter. An admin can only count own record therefore the number is at most 1.

    `,3),E=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),B=e("p",null,"inputs",-1),N=e("em",null,"where",-1),O={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},U=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),$=e("p",null,"The value can be expressed as either",-1),G=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),L={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},M=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),R=t(`
  • outcome

    • for super-admins or admin, a count matching the filter
    • forbidden otherwise
  • example

    to retrieve the count of administrators created in year 2023 , run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2),J=t(`

    Delete an Administrator

    DELETE /administrators/{id}
    +

    This API allows a super-admin or admin to delete administrator by id. An admin can only delete own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      • for super-admins or admin
        • all AccessToken of the Administrator are deleted
        • the corresponding UserCredential is deleted
        • the Administrator is deleted
      • forbidden otherwise

    Get an Administrator

    GET /administrators/{id}
    +

    This API allows a super-admin or admin to get administrator by id. An admin can only get own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      • for super-admins or admin, returns the Administrator
      • forbidden otherwise

    Update an Administrator

    PATCH /administrators/{id}
    +

    This API allows a super-admin or admin to update administrator fields by id. An admin can only update own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • user information

        {
        +  "username": "string",
        +  "email": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin, updates the Administrator
      • forbidden otherwise

    Replace an Administrator

    PUT /administrators/{id}
    +

    This API allows a super-admin or admin to replace administrator records by id. An admin can only replace own record. This API is different from Update an Administrator in that update/patch needs only to contain fields that are changed, ie the delta, whereas replace/put needs to contain all fields to be saved.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • user information

        {
        +  "username": "string",
        +  "email": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin, updates the Administrator. If password is also supplied, the password is handled same way as Set Password API
      • forbidden otherwise

    Get an Administrator's AccessTokens

    GET /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to get access tokens by Administrator id. An admin can only get own records.

    `,21),Q=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),X=e("p",null,"inputs",-1),H=e("li",null,[e("p",null,[e("em",null,"Administrator"),s(" id")]),e("ul",null,[e("li",null,"required: true"),e("li",null,"parameter type: path"),e("li",null,"data type: string")])],-1),z=t("

    a filter containing properties where, fields, order, skip, and limit

    • parameter name: filter
    • required: false
    • parameter type: query
    • data type: object

    The filter can be expressed as either

    ",3),F=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),V={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},K=e("code",null,'?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"',-1),W=t(`

    Regardless, the filter will have to be parsed into a JSON object conforming to

    {
    +    "where": {...},
    +    "fields": ...,
    +    "order": ...,
    +    "skip": ...,
    +    "limit": ...,
    +}
    +

    All properties are optional. The syntax for each property is documented, respectively

    `,3),Y=e("em",null,"where",-1),Z={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},ee=e("em",null,"fields",-1),se={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.select()",target:"_blank",rel:"noopener noreferrer"},ne=e("em",null,"order",-1),ae={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},te=e("em",null,"skip",-1),ie={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.skip/",target:"_blank",rel:"noopener noreferrer"},le=e("em",null,"limit",-1),re={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.limit/",target:"_blank",rel:"noopener noreferrer"},oe=t(`
  • outcome

    • for super-admins or admin, a list of AccessTokens matching the filter
    • forbidden otherwise
  • example

    to retrieve access tokens created in year 2023 for administrator with id of 1, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
    +

    the value of the filter query parameter is URL-encoded stringified JSON object

    {
    +  "where": {
    +    "created": {
    +      "$gte": "2023-01-01",
    +      "$lt": "2024-01-01"
    +    }
    +  }
    +}
    +
  • `,2),de=t(`

    Update an Administrator's AccessTokens

    PATCH /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to update access tokens by Administrator id. An admin can only update own records.

    `,3),pe=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),ue=e("p",null,"inputs",-1),ce=e("li",null,[e("p",null,[e("em",null,"Administrator"),s(" id")]),e("ul",null,[e("li",null,"required: true"),e("li",null,"parameter type: path"),e("li",null,"data type: string")])],-1),me=e("em",null,"where",-1),he={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},ve=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),be=e("p",null,"The value can be expressed as either",-1),ke=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),ge={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},qe=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),fe=t(`
  • AccessToken information

    {
    +  "ttl": 0,
    +  "name": "string"
    +}
    +
    • required: true
    • parameter type: request body
    • data type: object
  • `,1),ye=t(`
  • outcome

    • for super-admins or admin, success count
    • forbidden otherwise
  • example

    to set ttl token to 0 for all access tokens created in year 2023 for administrator with id 1, run

    curl -X PATCH --header 'Content-Type: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D' -d '{"ttl":0}'
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2),_e=t(`

    Create an Administrator's AccessToken

    POST /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to create an access token by Administrator id. An admin can only create own records.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • AccessToken information

        {
        +  "ttl": 0,
        +  "name": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin
        • Create and save AccessToken
        • return AccessToken created
      • forbidden otherwise

    Delete an Administrator's AccessTokens

    DELETE /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to delete access tokens by Administrator id. An admin can only delete own records.

    `,8),we=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),xe=e("p",null,"inputs",-1),Ae=e("li",null,[e("p",null,[e("em",null,"Administrator"),s(" id")]),e("ul",null,[e("li",null,"required: true"),e("li",null,"parameter type: path"),e("li",null,"data type: string")])],-1),je=e("em",null,"where",-1),Te={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},Pe=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),Ie=e("p",null,"The value can be expressed as either",-1),Se=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),De={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},Ce=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),Ee=t(`
  • outcome

    • for super-admins or admin
      • delete all AccessToken under the Administrator matching the filter
      • return success count
    • forbidden otherwise
  • example

    to delete all access tokens created in year 2023 for administrator with id 1, run

    curl -X DELETE --header 'Accept: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2);function Be(i,Ne){const n=r("ExternalLinkIcon");return o(),d("div",null,[u,e("img",{src:i.$withBase("/img/admin-data-models.svg"),alt:"administrator model diagram"},null,8,c),m,e("ul",null,[h,e("li",null,[v,e("ul",null,[e("li",null,[b,e("ol",null,[k,e("li",null,[s("in the format supported by "),e("a",g,[s("qs"),a(n)]),s(", for example "),q])]),f,e("ul",null,[e("li",null,[s("for "),y,s(" , see MongoDB "),e("a",_,[s("Query Documents"),a(n)])]),e("li",null,[s("for "),w,s(" , see Mongoose "),e("a",x,[s("select"),a(n)])]),e("li",null,[s("for "),A,s(", see Mongoose "),e("a",j,[s("sort"),a(n)])]),e("li",null,[s("for "),T,s(", see MongoDB "),e("a",P,[s("cursor.skip"),a(n)])]),e("li",null,[s("for "),I,s(", see MongoDB "),e("a",S,[s("cursor.limit"),a(n)])])])])])]),D]),C,e("ul",null,[E,e("li",null,[B,e("ul",null,[e("li",null,[e("p",null,[s("a "),N,s(" query parameter with value conforming to MongoDB "),e("a",O,[s("Query Documents"),a(n)])]),U,$,e("ol",null,[G,e("li",null,[s("in the format supported by "),e("a",L,[s("qs"),a(n)]),s(", for example "),M])])])])]),R]),J,e("ul",null,[Q,e("li",null,[X,e("ul",null,[H,e("li",null,[z,e("ol",null,[F,e("li",null,[s("in the format supported by "),e("a",V,[s("qs"),a(n)]),s(", for example "),K])]),W,e("ul",null,[e("li",null,[s("for "),Y,s(" , see MongoDB "),e("a",Z,[s("Query Documents"),a(n)])]),e("li",null,[s("for "),ee,s(" , see Mongoose "),e("a",se,[s("select"),a(n)])]),e("li",null,[s("for "),ne,s(", see Mongoose "),e("a",ae,[s("sort"),a(n)])]),e("li",null,[s("for "),te,s(", see MongoDB "),e("a",ie,[s("cursor.skip"),a(n)])]),e("li",null,[s("for "),le,s(", see MongoDB "),e("a",re,[s("cursor.limit"),a(n)])])])])])]),oe]),de,e("ul",null,[pe,e("li",null,[ue,e("ul",null,[ce,e("li",null,[e("p",null,[s("a "),me,s(" query parameter with value conforming to MongoDB "),e("a",he,[s("Query Documents"),a(n)])]),ve,be,e("ol",null,[ke,e("li",null,[s("in the format supported by "),e("a",ge,[s("qs"),a(n)]),s(", for example "),qe])])]),fe])]),ye]),_e,e("ul",null,[we,e("li",null,[xe,e("ul",null,[Ae,e("li",null,[e("p",null,[s("a "),je,s(" query parameter with value conforming to MongoDB "),e("a",Te,[s("Query Documents"),a(n)])]),Pe,Ie,e("ol",null,[Se,e("li",null,[s("in the format supported by "),e("a",De,[s("qs"),a(n)]),s(", for example "),Ce])])])])]),Ee])])}const Ue=l(p,[["render",Be],["__file","index.html.vue"]]);export{Ue as default}; diff --git a/preview/assets/index.html-1d87c569.js b/preview/assets/index.html-1d87c569.js new file mode 100644 index 000000000..95965fb7d --- /dev/null +++ b/preview/assets/index.html-1d87c569.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-6768263b","path":"/docs/overview/","title":"Overview","lang":"en-US","frontmatter":{"permalink":"/docs/overview/"},"headers":[{"level":2,"title":"Features","slug":"features","link":"#features","children":[{"level":3,"title":"notification","slug":"notification","link":"#notification","children":[]},{"level":3,"title":"subscription and un-subscription","slug":"subscription-and-un-subscription","link":"#subscription-and-un-subscription","children":[]},{"level":3,"title":"mail merge","slug":"mail-merge","link":"#mail-merge","children":[]}]},{"level":2,"title":"Architecture","slug":"architecture","link":"#architecture","children":[{"level":3,"title":"Request Types","slug":"request-types","link":"#request-types","children":[]},{"level":3,"title":"Authentication Strategies","slug":"authentication-strategies","link":"#authentication-strategies","children":[]}]},{"level":2,"title":"Application Framework","slug":"application-framework","link":"#application-framework","children":[]}],"git":{},"filePathRelative":"docs/getting-started/overview.md"}');export{e as data}; diff --git a/preview/assets/index.html-233fe616.js b/preview/assets/index.html-233fe616.js new file mode 100644 index 000000000..2aab74039 --- /dev/null +++ b/preview/assets/index.html-233fe616.js @@ -0,0 +1,4 @@ +import{_ as l,r as s,o as r,c as d,a as t,b as e,d as o,w as a,e as i}from"./app-73097456.js";const u={},h=t("h1",{id:"web-console",tabindex:"-1"},[t("a",{class:"header-anchor",href:"#web-console","aria-hidden":"true"},"#"),e(" Web Console")],-1),p=t("a",{href:"../installation"},"installing",-1),f=t("em",null,"NotifyBC",-1),m=t("em",null,"NotifyBC",-1),b={href:"http://localhost:3000",target:"_blank",rel:"noopener noreferrer"},_=t("p",null,"What you see in web console and what you get from API calls depend on how your requests are authenticated.",-1),y=t("h2",{id:"ip-whitelisting-authentication",tabindex:"-1"},[t("a",{class:"header-anchor",href:"#ip-whitelisting-authentication","aria-hidden":"true"},"#"),e(" Ip whitelisting authentication")],-1),g=t("span",{class:"material-icons"},"verified_user",-1),w=t("p",null,"To see the result of non super-admin requests, you can choose one of the following methods",-1),v=t("ul",null,[t("li",null,"customize admin ip list to omit localhost (127.0.0.1)"),t("li",null,"access web console from another ip not in the admin ip list")],-1),I=t("h2",{id:"client-certificate-authentication",tabindex:"-1"},[t("a",{class:"header-anchor",href:"#client-certificate-authentication","aria-hidden":"true"},"#"),e(" Client certificate authentication")],-1),x=t("em",null,"NotifyBC",-1),A=t("span",{class:"material-icons"},"verified",-1),k=i('

    Anonymous

    If you access web console from a client that is not in the admin ip list, you are by default anonymous user. Anonymous authentication status is indicated by the LOGINlogin button on top right corner of web console. Click the button to login.

    Access token authentication

    ',3),P=t("em",null,"Access Token",-1),C=t("em",null,"Access Token",-1),O=t("span",{class:"material-icons"},"login",-1),S=i('

    Tokens are not shared between API Explorer and web console

    Despite API Explorer appears to be part of web console, it is a separate application. At this point neither the access token nor the OIDC access token are shared between the two applications. You have to use API Explorer's Authorize button to authenticate even if you have logged into web console.

    OIDC authentication

    If you have configured OIDC, then the login button will direct you to OIDC provider's login page. Once login successfully, you will be redirected back to NoitfyBC web console. OIDC authentication status is indicated by the LOGOUTlogout button.

    ',3),q=i(`

    SiteMinder authentication

    To get results of a SiteMinder authenticated user, do one of the following

    • access the API via a SiteMinder proxy if you have configured SiteMinder properly
    • use a tool such as curl that allows to specify custom headers, and supply SiteMinder header SM_USER:
    curl -X GET --header "Accept: application/json" \\
    +    --header "SM_USER: foo" \\
    +    "http://localhost:3000/api/notifications"
    +
    `,4);function N(T,E){const c=s("ExternalLinkIcon"),n=s("RouterLink");return r(),d("div",null,[h,t("p",null,[e("After "),p,e(),f,e(", you can start exploring "),m,e(" resources by opening web console, a curated GUI, at "),t("a",b,[e("http://localhost:3000"),o(c)]),e(". You can further explore full-blown APIs by clicking the API explorer Swagger UI embedded in web console.")]),t("p",null,[e("Consult the "),o(n,{to:"/docs/api-overview/"},{default:a(()=>[e("API docs")]),_:1}),e(" for valid inputs and expected outcome while you are exploring the APIs. Once you are familiar with the APIs, you can start writing code to call the APIs from either user browser or from a server application.")]),_,y,t("p",null,[e("The API calls you made with API explorer as well as API calls made by web console from localhost are by default authenticated as "),o(n,{to:"/docs/overview/#architecture"},{default:a(()=>[e("super-admin requests")]),_:1}),e(" because localhost is in "),o(n,{to:"/docs/config-adminIpList/"},{default:a(()=>[e("admin ip list")]),_:1}),e(" by default. Ip whitelisting authentication status is indicated by the "),g,e(" icon on top right corner of web console.")]),w,v,I,t("p",null,[e("If your ip is not in the admin ip list but you have setup a client certificate issued by "),x,e(" server in browser, the API calls you made with API explorer as well as API calls made by web console are also authenticated as "),o(n,{to:"/docs/overview/#architecture"},{default:a(()=>[e("super-admin requests")]),_:1}),e(". Client certificate authentication status is indicated by the "),A,e(" icon on top right corner of web console.")]),k,t("p",null,[e("If you have not configured "),o(n,{to:"/docs/config/oidc.html"},{default:a(()=>[e("OIDC")]),_:1}),e(", the login button opens a login form. After successful login, the login button is replaced with the "),P,e(" text field on top right corner of web console. You can edit the text field. If the new access token you entered is invalid, you are essentially logging yourself out. In such case "),C,e(" text field is replaced by the LOGIN"),O,e(" button.")]),t("p",null,[e("The procedure to create an admin login account is documented in "),o(n,{to:"/docs/api/administrator.html"},{default:a(()=>[e("Administrator API")]),_:1})]),S,t("p",null,[e("If you passed "),o(n,{to:"/docs/config/oidc.html"},{default:a(()=>[e("isAdmin")]),_:1}),e(" validation, you are admin; otherwise you are authenticated user.")]),q])}const B=l(u,[["render",N],["__file","index.html.vue"]]);export{B as default}; diff --git a/preview/assets/index.html-27f35336.js b/preview/assets/index.html-27f35336.js new file mode 100644 index 000000000..1bfb248b5 --- /dev/null +++ b/preview/assets/index.html-27f35336.js @@ -0,0 +1,142 @@ +import{_ as c,r as o,o as r,c as l,a as s,b as n,d as a,w as i,e}from"./app-73097456.js";const u={},d=e(`

    Subscription

    Configs in this section customize behavior of subscription and unsubscription workflow. They are all sub-properties of config object subscription. This object can be defined as service-agnostic static config as well as service-specific dynamic config, which overrides the static one on a service-by-service basis. Default static config is defined in file /src/config.ts. There is no default dynamic config.

    To customize static config, create the config object subscription in file /src/config.local.js

    module.exports = {
    +  "subscription": {
    +    ...
    +  }
    +}
    +
    `,4),m=s("a",{id:"subscription-confirmation-request-template"},null,-1),v=e(`
    curl -X POST http://localhost:3000/api/configurations \\
    +-H 'Content-Type: application/json' \\
    +-H 'Accept: application/json' -d @- << EOF
    +{
    +  "name": "subscription",
    +  "serviceName": "myService",
    +  "value": {
    +     ...
    +  }
    +}
    +EOF
    +

    Sub-properties denoted by ellipsis in the above two code blocks are documented below. A service can have at most one dynamic subscription config.

    Confirmation Request Message

    To prevent NotifyBC from being used as spam engine, when a subscription request is sent by user (as opposed to admin) without encryption, the content of confirmation request sent to user's notification channel has to come from a pre-configured template as opposed to be specified in subscription request.

    The following default subscription sub-property confirmationRequest defines confirmation request message settings for different channels

    {
    +  "subscription": {
    +    ...
    +    "confirmationRequest": {
    +      "sms": {
    +        "confirmationCodeRegex": "\\\\d{5}",
    +        "sendRequest": true,
    +        "textBody": "Enter {confirmation_code} on screen"
    +      },
    +      "email": {
    +        "confirmationCodeRegex": "\\\\d{5}",
    +        "sendRequest": true,
    +        "from": "no_reply@invlid.local",
    +        "subject": "Subscription confirmation",
    +        "textBody": "Enter {confirmation_code} on screen",
    +        "htmlBody": "Enter {confirmation_code} on screen"
    +      }
    +    }
    +  }
    +}
    +

    Confirmation Verification Acknowledgement Messages

    You can customize NotifyBC's on-screen response message to confirmation code verification requests. The following is the default settings

    {
    +  "subscription": {
    +    ...
    +    "confirmationAcknowledgements": {
    +      "successMessage": "You have been subscribed.",
    +      "failureMessage": "Error happened while confirming subscription."
    +    }
    +  }
    +}
    +

    In addition to customizing the message, you can define a redirect URL instead of displaying successMessage or failureMessage. For example, to redirect on-screen acknowledgement to a page in your app for service myService, create a dynamic config by calling REST config api

    curl -X POST 'http://localhost:3000/api/configurations' \\
    +-H 'Content-Type: application/json' \\
    +-H 'Accept: application/json' -d @- << EOF
    +{
    +  "name": "subscription",
    +  "serviceName": "myService",
    +  "value": {
    +    "confirmationAcknowledgements": {
    +      "redirectUrl": "https://myapp/subscription/acknowledgement"
    +    }
    +  }
    +}
    +EOF
    +

    If error happened during subscription confirmation, query string ?err=<error> will be appended to redirectUrl.

    Duplicated Subscription

    NotifyBC by default allows a user subscribe to a service through same channel multiple times. If this is undesirable, you can set config subscription.detectDuplicatedSubscription to true. In such case instead of sending user a confirmation request, NotifyBC sends user a duplicated subscription notification message. Unlike a confirmation request, duplicated subscription notification message either shouldn't contain any information to allow user confirm the subscription, or it should contain a link that allows user to replace existing confirmed subscription with this one. You can customize duplicated subscription notification message by setting config subscription.duplicatedSubscriptionNotification in either config.local.js or using configuration api for service-specific dynamic config. Following is the default settings defined in config.json

    {
    +  ...
    +  "subscription": {
    +    ...
    +    "detectDuplicatedSubscription": false,
    +    "duplicatedSubscriptionNotification": {
    +      "sms": {
    +        "textBody": "A duplicated subscription was submitted and rejected. you will continue receiving notifications. If the request was not created by you, pls ignore this msg."
    +      },
    +      "email": {
    +        "from": "no_reply@invalid.local",
    +        "subject": "Duplicated Subscription",
    +        "textBody": "A duplicated subscription was submitted and rejected. you will continue receiving notifications. If the request was not created by you, please ignore this message."
    +      }
    +    }
    +  }
    +}
    +

    To allow user to replace existing confirmed subscription, set the message to something like

    {
    +  ...
    +  "subscription": {
    +    ...
    +    "detectDuplicatedSubscription": false,
    +    "duplicatedSubscriptionNotification": {
    +      "email": {
    +        "textBody": "A duplicated subscription was submitted. If the request is not submitted by you, please ignore this message. Otherwise if you want to replace existing subscription with this one, click {subscription_confirmation_url}&replace=true."
    +      }
    +    }
    +  }
    +}
    +

    The query parameter &replace=true following the token {subscription_confirmation_url} will cause existing subscription be replaced.

    Anonymous Unsubscription

    For anonymous subscription, NotifyBC supports one-click opt-out by allowing unsubscription URL provided in notifications. To thwart unauthorized unsubscription attempts, NotifyBC implemented and enabled by default two security measurements

    `,20),b=s("li",null,"Anonymous unsubscription request requires unsubscription code, which is a random string generated at subscription time. Unsubscription code reduces brute force attack risk by increasing size of key space. Without it, an attacker only needs to successfully guess subscription id. Be aware, however, the unsubscription code has to be embedded in unsubscription link. If the user forwarded a notification to other people, he/she is still vulnerable to unauthorized unsubscription.",-1),k=s("em",null,"anonymousUnsubscription",-1),g={href:"https://github.com/bcgov/NotifyBC/blob/main/src/config.ts",target:"_blank",rel:"noopener noreferrer"},f=e(`
    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      code: {
    +        required: true,
    +        regex: '\\\\d{5}',
    +      },
    +      acknowledgements: {
    +        onScreen: {
    +          successMessage: 'You have been un-subscribed.',
    +          failureMessage: 'Error happened while un-subscribing.',
    +        },
    +        notification: {
    +          email: {
    +            from: 'no_reply@invalid.local',
    +            subject: 'Un-subscription acknowledgement',
    +            textBody:
    +              'This is to acknowledge you have been un-subscribed from receiving notification for {unsubscription_service_names}. If you did not authorize this change or if you changed your mind, open {unsubscription_reversion_url} to revert.',
    +            htmlBody:
    +              'This is to acknowledge you have been un-subscribed from receiving notification for {unsubscription_service_names}. If you did not authorize this change or if you changed your mind, click <a href="{unsubscription_reversion_url}">here</a> to revert.',
    +          },
    +        },
    +      },
    +    },
    +  },
    +};
    +

    The settings control whether or not unsubscription code is required, its RegEx pattern, and acknowledgement message templates for both on-screen and push notifications. Customization should be made to file /src/config.local.js for static config or using configuration api for service-specific dynamic config.

    To disable acknowledgement notification, set subscription.anonymousUnsubscription.acknowledgements.notification or a specific channel underneath to null

    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      acknowledgements: {
    +        notification: null,
    +      },
    +    },
    +  },
    +};
    +

    For on-screen acknowledgement, you can define a redirect URL instead of displaying successMessage or failureMessage. For example, to redirect on-screen acknowledgement to a page in your app for all services, create following config in file /src/config.local.js

    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      acknowledgements: {
    +        onScreen: {
    +          redirectUrl: 'https://myapp/unsubscription/acknowledgement',
    +        },
    +      },
    +    },
    +  },
    +};
    +

    If error happened during unsubscription, query string ?err=<error> will be appended to redirectUrl.

    You can customize message displayed on-screen when user clicks revert unsubscription link in the acknowledgement notification. The default settings are

    {
    +  "subscription": {
    +    "anonymousUndoUnsubscription": {
    +      "successMessage": "You have been re-subscribed.",
    +      "failureMessage": "Error happened while re-subscribing."
    +    }
    +  }
    +}
    +

    You can redirect the message page by defining anonymousUndoUnsubscription.redirectUrl.

    `,10);function h(q,y){const t=o("RouterLink"),p=o("ExternalLinkIcon");return r(),l("div",null,[d,s("p",null,[m,n(" to create a service-specific dynamic subscription config, use REST "),a(t,{to:"/docs/api-config/"},{default:i(()=>[n("config api")]),_:1})]),v,s("ul",null,[b,s("li",null,[n("Acknowledgement notification - a (final) notification is sent to user acknowledging unsubscription, and offers a link to revert had the change been made unauthorized. A deleted subscription (unsubscription) may have a limited lifetime (30 days by default) according to retention policy defined in "),a(t,{to:"/docs/config-cronJobs/"},{default:i(()=>[n("cron jobs")]),_:1}),n(" so the reversion can only be performed within the lifetime.")])]),s("p",null,[n("You can customize anonymous unsubscription settings by changing the "),k,n(" configuration. Following is the default settings defined in "),s("a",g,[n("config.json"),a(p)])]),f])}const _=c(u,[["render",h],["__file","index.html.vue"]]);export{_ as default}; diff --git a/preview/assets/index.html-284021a2.js b/preview/assets/index.html-284021a2.js new file mode 100644 index 000000000..bba253316 --- /dev/null +++ b/preview/assets/index.html-284021a2.js @@ -0,0 +1 @@ +import{_ as s,r as a,o as c,c as l,a as e,b as i,d as t,w as o,e as r}from"./app-73097456.js";const d={},f=e("h1",{id:"configuration-overview",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#configuration-overview","aria-hidden":"true"},"#"),i(" Configuration Overview")],-1),u={class:"custom-container tip"},m=e("p",{class:"custom-container-title"},"Helm Chart Configurations",-1),g=e("em",null,"NoitfyBC",-1),h=e("em",null,"NotifyBC",-1),p=r('

    There are two types of configurations - static and dynamic. Static configurations are defined in files or environment variables, requiring restarting NotifyBC to take effect, whereas dynamic configurations are defined in databases and updates take effect immediately.

    Static Configurations

    Most static configurations are specified in file /src/config.ts. If you need to change, instead of updating /src/config.ts file, create local file /src/config.local.js or environment specific file /src/config.<env>.js, which is only included when environment variable NODE_ENV equals <env>. Besides js, ts and json file extensions are also supported. The rest of the documentation assumes the file extension is js. Content in these files are deeply merged in following ascending precedence

    • default file /src/config.ts
    • environment specific file /src/config.<env>.js
    • local file /src/config.local.js

    Run build script whenever changing file in /src

    Every time a file under /src, including config files, is updated, run npm run build before restarting NotifyBC to take effect.

    Following configs should be customized per installation

    ',6),_=e("p",null,"In addition, if installing from source code",-1),v=e("p",null,"Customizing other configs only if needed.",-1),y=e("h2",{id:"dynamic-configurations",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#dynamic-configurations","aria-hidden":"true"},"#"),i(" Dynamic Configurations")],-1),b=e("div",{class:"custom-container tip"},[e("p",{class:"custom-container-title"},"Why Dynamic Configs?"),e("p",null,"Dynamic configs are needed in cases such as"),e("ul",null,[e("li",null,"to allow define service-specific configs such as message templates"),e("li",null,"in a multi-node deployment, configs can be generated by one node (typically master) and shared with other nodes")])],-1);function w(x,C){const n=a("RouterLink");return c(),l("div",null,[f,e("div",u,[m,e("p",null,[i("The document pages in this section cover "),g,i(" app level configurations only. If your "),h,i(" is deployed to Kubernetes using Helm, you can also "),t(n,{to:"/docs/getting-started/installation.html#customizations"},{default:o(()=>[i("customize")]),_:1}),i(" infrastructure level configurations.")])]),p,e("ul",null,[e("li",null,[t(n,{to:"/docs/config/adminIpList.html"},{default:o(()=>[i("Admin IP List")]),_:1})]),e("li",null,[t(n,{to:"/docs/config/reverseProxyIpLists.html"},{default:o(()=>[i("Reverse Proxy IP Lists")]),_:1})]),e("li",null,[t(n,{to:"/docs/config/httpHost.html"},{default:o(()=>[i("HTTP Host")]),_:1})]),e("li",null,[t(n,{to:"/docs/config/email.html#smtp"},{default:o(()=>[i("SMTP")]),_:1})])]),_,e("ul",null,[e("li",null,[t(n,{to:"/docs/config/database.html"},{default:o(()=>[i("Database")]),_:1})]),e("li",null,[t(n,{to:"/docs/config/internalHttpHost.html"},{default:o(()=>[i("Internal HTTP Host")]),_:1})])]),v,y,e("p",null,[i("Dynamic configs are managed using REST "),t(n,{to:"/docs/api-config/"},{default:o(()=>[i("configuration api")]),_:1}),i(".")]),b])}const T=s(d,[["render",w],["__file","index.html.vue"]]);export{T as default}; diff --git a/preview/assets/index.html-2fb78bd6.js b/preview/assets/index.html-2fb78bd6.js new file mode 100644 index 000000000..6de3975e4 --- /dev/null +++ b/preview/assets/index.html-2fb78bd6.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-147825fb","path":"/docs/","title":"Welcome","lang":"en-US","frontmatter":{"permalink":"/docs/"},"headers":[{"level":2,"title":"Helpful Hints","slug":"helpful-hints","link":"#helpful-hints","children":[]}],"git":{},"filePathRelative":"docs/getting-started/index.md"}');export{e as data}; diff --git a/preview/assets/index.html-341d16d7.js b/preview/assets/index.html-341d16d7.js new file mode 100644 index 000000000..dc9186070 --- /dev/null +++ b/preview/assets/index.html-341d16d7.js @@ -0,0 +1,12 @@ +import{_ as l,r as o,o as p,c,a as e,b as s,d as n,w as i,e as r}from"./app-73097456.js";const u={},d=e("h1",{id:"bulk-import",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#bulk-import","aria-hidden":"true"},"#"),s(" Bulk Import")],-1),m={href:"https://www.mongodb.com/docs/database-tools/mongoimport/",target:"_blank",rel:"noopener noreferrer"},h=e("em",null,"NotifyBC",-1),f=e("li",null,[s("Software installed "),e("ul",null,[e("li",null,"Node.js"),e("li",null,"Git")])],-1),b=e("em",null,"NotifyBC",-1),v={href:"https://github.com/bcgov/NotifyBC/tree/main/src/utils/bulk-import/sample-subscription.csv",target:"_blank",rel:"noopener noreferrer"},k=e("em",null,"confirmationRequest.sendRequest",-1),_=r(`

    To run the utility

    git clone https://github.com/bcgov/NotifyBC.git
    +cd NotifyBC
    +npm i && npm run build
    +node dist/utils/bulk-import/subscription.js -a <api-url-prefix> -c <concurrency> <csv-file-path>
    +

    Here <csv-file-path> is the path to csv file and <api-url-prefix> is the NotifyBC api url prefix , default to http://localhost:3000/api.

    The script parses the csv file and generates a HTTP post request for each row. The concurrency of HTTP request is controlled by option -c which is default to 10 if omitted. A successful run should output the number of rows imported without any error message

    success row count = ***
    +

    Field Parsers

    `,6),g={href:"https://github.com/Keyang/node-csvtojson#custom-parsers",target:"_blank",rel:"noopener noreferrer"},y={href:"https://github.com/bcgov/NotifyBC/tree/main/src/utils/bulk-import/subscription.ts",target:"_blank",rel:"noopener noreferrer"},x=e("em",null,"src/utils/bulk-import/subscription.ts",-1),w=e("em",null,"myCustomIntegerField",-1),C=e("em",null,"colParser",-1),N=r(`
      colParser: {
    +    ...
    +    , myCustomIntegerField: (item, head, resultRow, row, colIdx) => {
    +      return parseInt(item)
    +    }
    +  }
    +
    `,1);function T(B,I){const t=o("ExternalLinkIcon"),a=o("RouterLink");return p(),c("div",null,[d,e("p",null,[s("To migrate subscriptions from other notification systems, you can use "),e("a",m,[s("mongoimport"),n(t)]),s(". "),h,s(" also provides a utility script to bulk import subscription data from a .csv file. To use the utility, you need")]),e("ul",null,[f,e("li",null,[s("Admin Access to a "),b,s(" instance by adding your client ip to the "),n(a,{to:"/docs/config-adminIpList/"},{default:i(()=>[s("Admin IP List")]),_:1})]),e("li",null,[s("a csv file with header row matching "),n(a,{to:"/docs/api-subscription/#model-schema"},{default:i(()=>[s("subscription model schema")]),_:1}),s(". A sample csv file is "),e("a",v,[s("provided"),n(t)]),s(". Compound fields (of object type) should be dot-flattened as shown in the sample for field "),k])]),_,e("p",null,[s("The utility script takes care of type conversion for built-in fields. If you need to import proprietary fields, by default the fields are imported as strings. To import non-string fields or manipulating json output, you need to define "),e("a",g,[s("custom parsers"),n(t)]),s(" in file "),e("a",y,[x,n(t)]),s(". For example, to parse "),w,s(" to integer, add in the "),C,s(" object")]),N])}const L=l(u,[["render",T],["__file","index.html.vue"]]);export{L as default}; diff --git a/preview/assets/index.html-34c15120.js b/preview/assets/index.html-34c15120.js new file mode 100644 index 000000000..e055ade3d --- /dev/null +++ b/preview/assets/index.html-34c15120.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-22e054a1","path":"/docs/config-workerProcessCount/","title":"Worker Process Count","lang":"en-US","frontmatter":{"permalink":"/docs/config-workerProcessCount/"},"headers":[],"git":{},"filePathRelative":"docs/config/workerProcessCount.md"}');export{e as data}; diff --git a/preview/assets/index.html-36d5ffa2.js b/preview/assets/index.html-36d5ffa2.js new file mode 100644 index 000000000..48fc7120f --- /dev/null +++ b/preview/assets/index.html-36d5ffa2.js @@ -0,0 +1,91 @@ +import{_ as p,r as i,o as c,c as u,a as e,b as n,d as s,w as l,e as t}from"./app-73097456.js";const d={},m=t(`

    Email

    SMTP

    By default NotifyBC acts as the SMTP server itself and connects directly to recipient's SMTP server. To setup SMTP relay to a host, say smtp.foo.com, add following smtp config object to /src/config.local.js

    module.exports = {
    +  email: {
    +    smtp: {
    +      host: 'smtp.foo.com',
    +      port: 25,
    +      pool: true,
    +      tls: {
    +        rejectUnauthorized: false,
    +      },
    +    },
    +  },
    +};
    +
    `,4),b={href:"https://nodemailer.com/smtp/",target:"_blank",rel:"noopener noreferrer"},h=e("em",null,"smtp",-1),v=t(`

    Throttle

    NotifyBC can throttle email requests if SMTP server imposes rate limit. To enable throttle and set rate limit, create following config in file /src/config.local.js

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +    },
    +  },
    +};
    +

    where

    • enabled - whether to enable throttle or not. Default to false.
    • minTime - minimum request interval in ms. Example value 250 throttles request rate to 4/sec.

    When NotifyBC is deployed from source code, by default the rate limit applies to one Node.js process only. If there are multiple processes, i.e. a cluster, the aggregated rate limit is multiplied by the number of processes. To enforce the rate limit across entire cluster, install Redis and add Redis config to email.throttle

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        host: '127.0.0.1',
    +        port: 6379,
    +      },
    +    },
    +  },
    +};
    +

    If you installed Redis Sentinel,

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        name: 'mymaster',
    +        sentinels: [{ host: '127.0.0.1', port: 26379 }],
    +      },
    +    },
    +  },
    +};
    +
    `,9),k={href:"https://github.com/SGrondin/bottleneck",target:"_blank",rel:"noopener noreferrer"},f={href:"https://github.com/luin/ioredis",target:"_blank",rel:"noopener noreferrer"},y=e("em",null,"NotifyBC",-1),g=e("em",null,"jobExpiration",-1),_=e("em",null,"expiration",-1),x={href:"https://github.com/bcgov/NotifyBC/blob/main/src/config.ts",target:"_blank",rel:"noopener noreferrer"},w=t(`

    When NotifyBC is deployed to Kubernetes using Helm, by default throttle, if enabled, uses Redis Sentinel therefore rate limit applies to whole cluster.

    Inbound SMTP Server

    NotifyBC implemented an inbound SMTP server to handle

    In order for the emails from internet to reach the SMTP server, a host where one of the following servers should be listening on port 25 open to internet

    1. NotifyBC, if it can be installed on such internet-facing host directly; otherwise,
    2. a tcp proxy server, such as nginx with stream proxy module that can proxy tcp port 25 traffic to backend NotifyBC instances.

    Regardless which above option is chosen, you need to config NotifyBC inbound SMTP server by adding following static config email.inboundSmtpServer to file /src/config.local.js

    module.exports = {
    +  email: {
    +    inboundSmtpServer: {
    +      enabled: true,
    +      domain: 'host.foo.com',
    +      listeningSmtpPort: 25,
    +      options: {
    +        // ...
    +      },
    +    },
    +  },
    +};
    +

    where

    `,9),S=t("
  • enabled enables/disables the inbound SMTP server with default to true.
  • domain is the internet-facing host domain. It has no default so must be set.
  • listeningSmtpPort should be set to 25 if option 1 above is chosen. For options 2, listeningSmtpPort can be set to any opening port. On Unix, NotifyBC has to be run under root account to bind to port 25. If missing, NotifyBC will randomly select an available port upon launch which is usually undesirable so it should be set.
  • ",3),T=e("em",null,"options",-1),N={href:"https://nodemailer.com/extras/smtp-server/#step-3-create-smtpserver-instance",target:"_blank",rel:"noopener noreferrer"},B=e("div",{class:"custom-container warning"},[e("p",{class:"custom-container-title"},"Inbound SMTP Server on OpenShift"),e("p",null,"OpenShift deployment template deploys an inbound SMTP server. Due to the limitation that OpenShift can only expose port 80 and 443 to external, to use the SMTP server, you have to setup a TCP proxy server (i.e. option 2). The inbound SMTP server is exposed as ${INBOUND_SMTP_DOMAIN}:443 , where ${INBOUND_SMTP_DOMAIN} is a template parameter which in absence, a default domain will be created. Configure your TCP proxy server to route traffic to ${INBOUND_SMTP_DOMAIN}:443 over TLS.")],-1),j=e("h3",{id:"tcp-proxy-server",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#tcp-proxy-server","aria-hidden":"true"},"#"),n(" TCP Proxy Server")],-1),P=e("em",null,"NotifyBC",-1),C={href:"http://nginx.org/en/docs/stream/ngx_stream_proxy_module.html",target:"_blank",rel:"noopener noreferrer"},I=e("em",null,"NotifyBC",-1),M=t(`
    stream {
    +    server {
    +        listen 25;
    +        proxy_pass \${INBOUND_SMTP_DOMAIN}:443;
    +        proxy_ssl on;
    +        proxy_ssl_verify off;
    +        proxy_ssl_server_name on;
    +        proxy_connect_timeout 10s;
    +    }
    +}
    +

    Replace \${INBOUND_SMTP_DOMAIN} with the inbound SMTP server route domain.

    Bounce

    Bounces, or Non-Delivery Reports (NDRs), are system-generated emails informing sender of failed delivery. NotifyBC can be configured to receive bounces, record bounces, and automatically unsubscribe all subscriptions of a recipient if the number of recorded hard bounces against the recipient exceeds threshold. A deemed successful notification delivery deletes the bounce record.

    Although NotifyBC records all bounce emails, not all of them should count towards unsubscription threshold, but rather only the hard bounces - those which indicate permanent unrecoverable errors such as destination address no longer exists. In principle this can be distinguished using smtp response code. In practice, however, there are some challenges to make the distinction

    • the smtp response code is not fully standardized and may vary by recipient's smtp server so it's unreliable
    • there is no standard smtp header in bounce email to contain smtp response code. Often the response code is embedded in bounce email body.
    • the bounce email template varies by sender's smtp server

    To mitigate, NotifyBC defines several customizable string pattern filters in terms of regular expression. Only bounce emails matched the filters count towards unsubscription threshold. It's a matter of trial-and-error to get the correct filter suitable to your smtp server.

    to improve hard bounce recognition

    Send non-existing emails to several external email systems. Inspect the bounce messages for common string patterns. After gone live, review bounce records in web console from time to time to identify new bounce types and decide whether the bounce types qualify as hard bounce. To avoid false positives resulting in premature unsubscription, it is advisable to start with a high unsubscription threshold.

    Bounce handling involves four actions

    `,9),R={href:"https://en.wikipedia.org/wiki/Variable_envelope_return_path",target:"_blank",rel:"noopener noreferrer"},O=e("em",null,"bn-{subscriptionId}-{unsubscriptionCode}@{inboundSmtpServerDomain}",-1),D=e("em",null,"NotifyBC",-1),z=e("li",null,"when a notification finished dispatching, the dispatching start and end time is recorded to all bounce records matching affects recipient addresses",-1),U=e("li",null,"when inbound smtp server receives a bounce message, it updates the bounce record by saving the message and incrementing the hard bounce count when the message matches the filter criteria. The filter criteria are regular expressions matched against bounce email subject and body, as well as regular expression to extract recipient's email address from bounce email body. It also unsubscribes the user from all subscriptions when the hard bounce count exceeds a predefined threshold.",-1),E=e("li",null,"A cron job runs periodically to delete bounce records if the latest notification is deemed delivered successfully.",-1),q=e("p",null,"To setup bounce handling",-1),A=e("li",null,[e("p",null,[n("set up "),e("a",{href:"#inbound-smtp-server"},"inbound smtp server")])],-1),$=e("li",null,[e("p",null,[n("verify config "),e("em",null,"email.bounce.enabled"),n(" is set to true or absent in "),e("em",null,"/src/config.local.js")])],-1),L=e("em",null,"/src/config.ts",-1),F={href:"https://tools.ietf.org/html/rfc3464",target:"_blank",rel:"noopener noreferrer"},V=t(`
    module.exports = {
    +  email: {
    +    bounce: {
    +      enabled: true,
    +      unsubThreshold: 5,
    +      subjectRegex: '',
    +      smtpStatusCodeRegex: '5\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}',
    +      failedRecipientRegex:
    +        '(?:[a-z0-9!#$%&\\'*+/=?^_\`{|}~-]+(?:\\\\.[a-z0-9!#$%&\\'*+/=?^_\`{|}~-]+)*|"(?:[\\\\x01-\\\\x08\\\\x0b\\\\x0c\\\\x0e-\\\\x1f\\\\x21\\\\x23-\\\\x5b\\\\x5d-\\\\x7f]|\\\\\\\\[\\\\x01-\\\\x09\\\\x0b\\\\x0c\\\\x0e-\\\\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\\\x01-\\\\x08\\\\x0b\\\\x0c\\\\x0e-\\\\x1f\\\\x21-\\\\x5a\\\\x53-\\\\x7f]|\\\\\\\\[\\\\x01-\\\\x09\\\\x0b\\\\x0c\\\\x0e-\\\\x7f])+)\\\\])',
    +    },
    +  },
    +};
    +

    where

    `,2),W=e("li",null,[e("p",null,[e("em",null,"unsubThreshold"),n(" is the threshold of hard bounce count above which the user is unsubscribed from all subscriptions")])],-1),G=e("li",null,[e("p",null,[e("em",null,"subjectRegex"),n(" is the regular expression that bounce message subject must match in order to count towards the threshold. If "),e("em",null,"subjectRegex"),n(" is set to empty string or "),e("em",null,"undefined"),n(", then this filter is disabled.")])],-1),H=e("em",null,"smtpStatusCodeRegex",-1),J={href:"https://tools.ietf.org/html/rfc3463",target:"_blank",rel:"noopener noreferrer"},K=e("ul",null,[e("li",null,[e("em",null,"message/delivery-status")]),e("li",null,"html"),e("li",null,"plain text")],-1),Q=e("em",null,"failedRecipientRegex",-1),X=e("em",null,"failedRecipientRegex",-1),Y=e("em",null,"undefined",-1),Z={href:"https://stackoverflow.com/questions/201323/how-to-validate-an-email-address-using-a-regular-expression",target:"_blank",rel:"noopener noreferrer"},ee=e("ul",null,[e("li",null,[e("em",null,"message/delivery-status")]),e("li",null,"html"),e("li",null,"plain text")],-1),ne=e("h2",{id:"list-unsubscribe-by-email",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#list-unsubscribe-by-email","aria-hidden":"true"},"#"),n(" List-unsubscribe by Email")],-1),se=e("p",null,"Some email clients provide a consistent UI to unsubscribe if an unsubscription email address is supplied. For example, newer iOS built-in email app will display following banner",-1),ae=["src"],te=t(`

    To support this unsubscription method, NotifyBC implements a custom inbound SMTP server to transform received emails sent to address un-{subscriptionId}-{unsubscriptionCode}@{inboundSmtpServerDomain} to NotifyBC unsubscribing API calls. This unsubscription email address is generated by NotifyBC and set in header List-Unsubscribe of all notification emails.

    To enable list-unsubscribe by email

    • set up inbound smtp server
    • verify config email.listUnsubscribeByEmail.enabled is set to true or absent in /src/config.local.js

    To disable list-unsubscribe by email, set email.listUnsubscribeByEmail.enabled to false in /src/config.local.js

    module.exports = {
    +  email: {
    +    listUnsubscribeByEmail: { enabled: false },
    +  },
    +};
    +
    `,5);function oe(r,ie){const a=i("ExternalLinkIcon"),o=i("RouterLink");return c(),u("div",null,[m,e("p",null,[n("Check out "),e("a",b,[n("Nodemailer"),s(a)]),n(" for other config options that you can define in "),h,n(" object. Using SMTP relay and fine-tuning some options are critical for performance. See "),s(o,{to:"/docs/benchmarks/#advices"},{default:l(()=>[n("benchmark advices")]),_:1}),n(".")]),v,e("p",null,[n("Throttle is implemented using "),e("a",k,[n("Bottleneck"),s(a)]),n(" and "),e("a",f,[n("ioredis"),s(a)]),n(". See their documentations for more configurations. The only deviation made by "),y,n(" is using "),g,n(" to denote Bottleneck "),_,n(" job option with a default value of 2min as defined in "),e("a",x,[n("config.ts"),s(a)]),n(".")]),w,e("ul",null,[S,e("li",null,[n("optional "),T,n(" object defines the behavior of "),e("a",N,[n("Nodemailer SMTP Server"),s(a)]),n(".")])]),B,j,e("p",null,[n("If "),P,n(" is not able to bind to port 25 that opens to internet, perhaps due to firewall restriction, you can setup a TCP Proxy Server such as Nginx with "),e("a",C,[n("ngx_stream_proxy_module"),s(a)]),n(". For example, the following nginx config will proxy SMTP traffic from port 25 to a "),I,n(" inbound SMTP server running on OpenShift")]),M,e("ul",null,[e("li",null,[n("during notification dispatching, envelop address is set to a "),e("a",R,[n("VERP"),s(a)]),n(" in the form "),O,n(" routed to "),D,n(" inbound smtp server.")]),z,U,E]),q,e("ul",null,[A,$,e("li",null,[e("p",null,[n("verify and adjust unsubscription threshold and bounce filter criteria if needed. Following is the default config in file "),L,n(" compatible with "),e("a",F,[n("rfc 3464"),s(a)])]),V,e("ul",null,[W,G,e("li",null,[e("p",null,[H,n(" is the regular expression that smtp status code embedded in the message body must match in order to count towards the threshold. The default value matches all "),e("a",J,[n("rfc3463"),s(a)]),n(" class 5 status codes. For a multi-part bounce message, the body limits to the one of the following parts by content type in descending order")]),K]),e("li",null,[e("p",null,[Q,n(" is the regular expression used to extract recipient's email address from bounce message body. This extracted recipient's email address is compared against the subscription record as a means of validation. If "),X,n(" is set to empty string or "),Y,n(", then this validation method is skipped. The default RegEx is taken from a "),e("a",Z,[n("stackoverflow answer"),s(a)]),n(". For a multi-part bounce message, the body limits to the one of the following parts by content type in descending order")]),ee])])]),e("li",null,[e("p",null,[n("Change config of cron job "),s(o,{to:"/docs/config-cronJobs/#delete-notification-bounces"},{default:l(()=>[n("Delete Notification Bounces")]),_:1}),n(" if needed")])])]),ne,se,e("img",{src:r.$withBase("/img/list-unsubscription.png"),alt:"list unsubscription"},null,8,ae),te])}const re=p(d,[["render",oe],["__file","index.html.vue"]]);export{re as default}; diff --git a/preview/assets/index.html-3a8f0d9c.js b/preview/assets/index.html-3a8f0d9c.js new file mode 100644 index 000000000..c9c234b33 --- /dev/null +++ b/preview/assets/index.html-3a8f0d9c.js @@ -0,0 +1 @@ +import{_ as t,o,c as s,a as e,b as n}from"./app-73097456.js";const a={},l=e("h1",{id:"node-roles",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#node-roles","aria-hidden":"true"},"#"),n(" Node Roles")],-1),d=e("p",null,[n("In a multi-node deployment, some tasks should only be run by one node. That node is designated as "),e("em",null,"master"),n(". The distinction is made using environment variable "),e("em",null,"NOTIFYBC_NODE_ROLE"),n(". Setting to anything other than "),e("em",null,"slave"),n(", including not set, will be regarded as "),e("em",null,"master"),n(".")],-1),i=[l,d];function r(c,_){return o(),s("div",null,i)}const m=t(a,[["render",r],["__file","index.html.vue"]]);export{m as default}; diff --git a/preview/assets/index.html-3bc7b1c6.js b/preview/assets/index.html-3bc7b1c6.js new file mode 100644 index 000000000..427742529 --- /dev/null +++ b/preview/assets/index.html-3bc7b1c6.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-26e624c6","path":"/docs/config-sms/","title":"SMS","lang":"en-US","frontmatter":{"permalink":"/docs/config-sms/"},"headers":[{"level":2,"title":"Provider","slug":"provider","link":"#provider","children":[]},{"level":2,"title":"Provider Settings","slug":"provider-settings","link":"#provider-settings","children":[{"level":3,"title":"Twilio","slug":"twilio","link":"#twilio","children":[]},{"level":3,"title":"Swift","slug":"swift","link":"#swift","children":[]}]},{"level":2,"title":"Throttle","slug":"throttle","link":"#throttle","children":[]}],"git":{},"filePathRelative":"docs/config/sms.md"}');export{e as data}; diff --git a/preview/assets/index.html-40029d88.js b/preview/assets/index.html-40029d88.js new file mode 100644 index 000000000..aac9fc12b --- /dev/null +++ b/preview/assets/index.html-40029d88.js @@ -0,0 +1,60 @@ +import{_ as r,r as n,o as l,c as d,a as e,b as t,d as a,w as p,e as s}from"./app-73097456.js";const c={},u=s(`

    Notification

    The notification API encapsulates the backend workflow of staging and dispatching a message to targeted user after receiving the message from event source.

    Depending on whether an API call comes from user browser as a user request or from an authorized server application as an admin request, NotifyBC applies different permissions. Admin request allows full CRUD operations. An authenticated user request, on the other hand, are only allowed to get a list of in-app pull notifications targeted to the current user and changing the state of the notifications. An unauthenticated user request can not access any API.

    When a notification is created by the event source server application, the message is saved to database prior to responding to API caller. In addition, for push notification, the message is delivered immediately, i.e. the API call is synchronous. For in-app pull notification, the message, which by default is in state new, can be retrieved later on by browser user request. A user request can only get the list of in-app messages targeted to the current user. A user request can then change the message state to read or deleted depending on user action. A deleted message cannot be retrieved subsequently by user requests, but the state can be updated given the correct id.

    Deleted message is still kept in database.

    NotifyBC provides API for deleting a notification. For the purpose of auditing and recovery, this API only marks the state field as deleted rather than deleting the record from database.

    undo in-app notification deletion within a session

    Because "deleted" message is still kept in database, you can implement undo feature for in-app notification as long as the message id is retained prior to deletion within the current session. To undo, call update API to set desired state.

    In-app pull notification also supports message expiration by setting a date in field validTill. An expired message cannot be retrieved by user requests.

    A message, regardless of push or pull, can be unicast or broadcast. A unicast message is intended for an individual user whereas a broadcast message is intended for all confirmed subscribers of a service. A unicast message must have field userChannelId populated. The value of userChannelId is channel dependent. In the case of email for example, this would be user's email address. A broadcast message must set isBroadcast to true and leave userChannelId empty.

    Why field isBroadcast?

    Unicast and broadcast message can be distinguished by whether field userChannelId is empty or not alone. So why the extra field isBroadcast? This is in order to prevent inadvertent marking a unicast message broadcast by omitting userChannelId or populating it with empty value. The precaution is necessary because in-app notifications may contain personalized and confidential information.

    NotifyBC ensures the state of an in-app broadcast message is isolated by user, so that for example, a message read by one user is still new to another user. To achieve this, NotifyBC maintains two internal fields of array type - readBy and deletedBy. When a user request updates the state field of an in-app broadcast message to read or deleted, instead of altering the state field, NotifyBC appends the current user to readBy or deletedBy list. When user request retrieving in-app messages, the state field of the broadcast message in HTTP response is updated based on whether the user exists in field deletedBy and readBy. If existing in both fields, deletedBy takes precedence (the message therefore is not returned). The record in database, meanwhile, is unchanged. Neither field deletedBy nor readBy is visible to user request.

    Model Schema

    The API operates on following notification data model fields:

    NameAttributes

    id

    notification id

    typestring, format depends on db
    auto-generatedtrue

    serviceName

    name of the service

    typestring
    requiredtrue

    channel

    name of the delivery channel. Valid values: inApp, email, sms.

    typestring
    requiredtrue
    defaultinApp

    userChannelId

    user's delivery channel id, for example, email address. For unicast inApp notification, this is authenticated user id. When sending unicast push notification, either userChannelId or userId is required.

    typestring
    requiredfalse

    userId

    authenticated user id. When sending unicast push notification, either userChannelId or userId is required.

    typestring
    requiredfalse

    state

    state of notification. Valid values: new, read (inApp only), deleted (inApp only), sent (push only) or error. For inApp broadcast notification, if the user has read or deleted the message, the value of this field retrieved by admin request will still be new. The state for the user is tracked in fields readBy and deletedBy in such case. For user request, the value contains correct state.

    typestring
    requiredtrue
    defaultnew

    created

    date and time of creation

    typedate
    auto-generatedtrue

    updated

    date and time of last update

    typedate
    auto-generatedtrue

    isBroadcast

    whether it's a broadcast message. A broadcast message should omit userChannelId and userId, in addition to setting isBroadcast to true

    typeboolean
    requiredfalse
    defaultfalse

    skipSubscriptionConfirmationCheck

    When sending unicast push notification, whether or not to verify if the recipient has a confirmed subscription. This field allows subscription information be kept elsewhere and NotifyBC be used as a unicast push notification gateway only.

    typeboolean
    requiredfalse
    defaultfalse

    validTill

    expiration date-time of the message. Applicable to inApp notification only.

    typedate
    requiredfalse

    invalidBefore

    date-time in the future after which the notification can be dispatched.

    typedate
    requiredfalse

    message

    an object whose child fields are channel dependent:
    • for inApp, NotifyBC doesn't have any restriction as long as web application can handle the message. subject and body are common examples.
    • for email: from, subject, textBody, htmlBody
      • type: string
      • these are email template fields.
    • for sms: textBody
      • type: string
      • sms message template.
    Mail merge is performed on email and sms message templates.
    typeobject
    requiredtrue

    httpHost

    This field is used to replace token {http_host} in push notification message template during mail merge and overrides config httpHost.

    typestring
    requiredfalse
    default<http protocol, host and port of current request> for push notification

    asyncBroadcastPushNotification

    this field determines if the API call to create an immediate (i.e. not future-dated) broadcast push notification is asynchronous or not. If omitted, the API call is synchronous, i.e. the API call blocks until notifications to all subscribers have been dispatched. If set, valid values and corresponding behaviors are
    • true - async without callback
    • false - sync
    • a string containing callback url - async with a POST call to the supplied callback url upon completion
    When posting to a service with large number of subscribers, it is highly recommended to set the API call to asynchronous, i.e. setting the value to true or supplying a callback.
    typestring or boolean
    requiredfalse
    defaultfalse

    data

    the event that triggers the notification, for example, a RSS feed item when the notification is generated automatically by RSS cron job. Field data serves two purposes
    • to replace dynamic tokens in message template fields
    • to match against filter defined in subscription field broadcastPushNotificationFilter, if supplied, for broadcast push notifications to determine if the notification should be delivered to the subscriber
    typeobject
    requiredfalse

    broadcastPushNotificationSubscriptionFilter

    a string conforming to jmespath filter expressions syntax after the question mark (?). The filter is matched against the data field of the subscription. Examples of filter
    • simple
      province == 'BC'
    • calling jmespath's built-in functions
      contains(province,'B')
    • calling custom filter functions
      contains_ci(province,'b')
    • compound
      (contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria'
    All of above filters will match data object {"province": "BC", "city": "Victoria"}
    typestring
    requiredfalse

    readBy

    this is an internal field to track the list of users who have read an inApp broadcast message. It's not visible to a user request.

    typearray
    requiredfalse
    auto-generatedtrue

    deletedBy

    this is an internal field to track the list of users who have marked an inApp broadcast message as deleted. It's not visible to a user request.

    typearray
    requiredfalse
    auto-generatedtrue

    dispatch

    this is an internal field to track the broadcast push notification dispatch outcome. It consists of up to four arrays

    • failed - a list of objects containing subscription IDs and error of failed dispatching
    • successful - a list of strings containing subscription IDs of successful dispatching
    • skipped - a list of strings containing subscription IDs of skipped dispatching
    • candidates - a list of strings containing IDs of confirmed subscriptions to the service. Dispatching to a subscription is subject to filtering.
    typeobject
    requiredfalse
    auto-generatedtrue

    Get Notifications

    GET /notifications
    +
    `,15),m=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin"),e("li",null,"authenticated user")])],-1),h=e("p",null,"inputs",-1),f=s("

    a filter containing properties where, fields, order, skip, and limit

    • parameter name: filter
    • required: false
    • parameter type: query
    • data type: object

    The filter can be expressed as either

    ",3),b=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),g={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},v=e("code",null,'?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"',-1),y=s(`

    Regardless, the filter will have to be parsed into a JSON object conforming to

    {
    +    "where": {...},
    +    "fields": ...,
    +    "order": ...,
    +    "skip": ...,
    +    "limit": ...,
    +}
    +

    All properties are optional. The syntax for each property is documented, respectively

    `,3),k=e("em",null,"where",-1),q={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},_=e("em",null,"fields",-1),w={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.select()",target:"_blank",rel:"noopener noreferrer"},x=e("em",null,"order",-1),I={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},B=e("em",null,"skip",-1),j={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.skip/",target:"_blank",rel:"noopener noreferrer"},A=e("em",null,"limit",-1),C={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.limit/",target:"_blank",rel:"noopener noreferrer"},T=s(`
  • outcome

    • for admin requests, returns unabridged array of notification data matching the filter
    • for authenticated user requests, in addition to filter, following constraints are imposed on the returned array
      • only inApp notifications
      • only non-deleted notifications. For broadcast notification, non-deleted means not marked by current user as deleted
      • only non-expired notifications
      • for unicast notifications, only the ones targeted to current user
      • if current user is in readBy, then the state is changed to read
      • the internal field readBy and deletedBy are removed
    • forbidden to anonymous user requests
  • example

    to retrieve notifications created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/notifications?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
    +

    the value of the filter query parameter is URL-encoded stringified JSON object

    {
    +  "where": {
    +    "created": {
    +      "$gte": "2023-01-01",
    +      "$lt": "2024-01-01"
    +    }
    +  }
    +}
    +
  • `,2),N=s(`

    Get Notification Count

    GET /notifications/count
    +
    `,2),P=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin"),e("li",null,"authenticated user")])],-1),S=e("p",null,"inputs",-1),D=e("em",null,"where",-1),F={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},E=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),R=e("p",null,"The value can be expressed as either",-1),L=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),U={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},V=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),M=s(`
  • outcome

    Validations rules are the same as GET /notifications. If passed, the output is a count of notifications matching the query

    {
    +  "count": <number>
    +}
    +
  • example

    to retrieve the count of notifications created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/notifications/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2),O=s(`

    Create/Send Notifications

    POST /notifications
    +
    `,2),W=s('
  • permissions required, one of

    • super admin
    • admin
  • inputs

    • an object containing notification data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated. The API explorer only created an empty object for field message but you should populate the child fields according to model schema
      • parameter name: data
      • required: true
      • parameter type: body
      • data type: object
  • ',2),$=e("p",null,"outcome",-1),G=e("p",null,[e("em",null,"NotifyBC"),t(" performs following actions in sequence")],-1),H=s("
  • if it's a user request, error is returned

  • inputs are validated. If validation fails, error is returned. In particular, for unicast push notification, the recipient as identified by either userChannelId or userId must have a confirmed subscription if field skipSubscriptionConfirmationCheck is not set to true. If skipSubscriptionConfirmationCheck is set to true, then the subscription check is skipped, but in such case the request must contain userChannelId, not userId as subscription data is not queried to obtain userChannelId from userId.

  • for push notification, if field httpHost is empty, it is populated based on request's http protocol and host.

  • the notification request is saved to database

  • if the notification is future-dated, then all subsequent request processing is skipped and response is sent back to user. Steps 7-11 below will be carried out later on by the cron job when the notification becomes current.

  • if it's an async broadcast push notification, then response is sent back to user but steps 7-12 below is processed separately

  • ",6),J=e("p",null,"for unicast push notification, the message is dispatched to targeted user; for broadcast push notification, following actions are performed:",-1),Q=e("li",null,[e("p",null,"number of confirmed subscriptions is retrieved")],-1),z=s("
  • when processing an individual subscription,

    1. if the subscription has filter rule defined in field broadcastPushNotificationFilter and notification contains field data, then the data is matched against the filter rule. Notification message is only dispatched if there is a match.
    2. if the notification has filter rule defined in field broadcastPushNotificationSubscriptionFilter and subscription contains field data, then the data is matched against the filter rule. Notification message is only dispatched if there is a match.

    If the subscription failed to pass any of the two filters, and if both guaranteedBroadcastPushDispatchProcessing and logSkippedBroadcastPushDispatches are true, the subscription id is logged to dispatch.skipped

  • ",1),X=e("p",null,"Regardless of unicast or broadcast, mail merge is performed on messages before dispatching.",-1),Z=s("
  • the state of push notification is updated to sent or error depending on sending status. For broadcast push notification, the dispatching could be failed only for a subset of users. In such case, the field dispatch.failed contains a list of objects of {userChannelId, subscriptionId, error} the message failed to deliver to, but the state will still be set to sent.

  • For broadcast push notifications, if guaranteedBroadcastPushDispatchProcessing is true, then field dispatch.successful is populated with a list of subscriptionId of the successful dispatches.

  • For push notifications, the bounce records of successful dispatches are updated

  • the updated notification is saved back to database

  • if it's an async broadcast push notification with a callback url, then the url is called with POST verb containing the notification with updated status as the request body

  • for synchronous notification, the saved record is returned unless there is an error saving to database, in which case error is returned

  • ",6),K=s(`
  • example

    To send a unicast email push notification, copy and paste following json object to the data value box in API explorer, change email addresses as needed, and click Try it out! button:

    {
    +  "serviceName": "education",
    +  "userChannelId": "foo@bar.com",
    +  "skipSubscriptionConfirmationCheck": true,
    +  "message": {
    +    "from": "no_reply@bar.com",
    +    "subject": "test",
    +    "textBody": "This is a test"
    +  },
    +  "channel": "email"
    +}
    +

    As the result, foo@bar.com should receive an email notification even if the user is not a confirmed subscriber, and following json object is returned to caller upon sending the email successfully:

    {
    +  "serviceName": "education",
    +  "state": "sent",
    +  "userChannelId": "foo@bar.com",
    +  "skipSubscriptionConfirmationCheck": true,
    +  "message": {
    +    "from": "no_reply@bar.com",
    +    "subject": "test",
    +    "textBody": "This is a test"
    +  },
    +  "created": "2016-09-30T20:37:06.011Z",
    +  "updated": "2016-09-30T20:37:06.011Z",
    +  "channel": "email",
    +  "isBroadcast": false,
    +  "id": "57eeccf23427b61a4820775e"
    +}
    +
  • `,1),Y=s(`

    Update a Notification

    PATCH /notifications/{id}
    +

    This API is mainly used for updating an inApp notification.

    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      • for user requests, NotifyBC performs following actions in sequence
        1. for unicast notification, if the notification is not targeted to current user, error is returned
        2. all fields except for state are discarded from the input
        3. for broadcast notification, current user id in appended to array readBy or deletedBy, depending on whether state is read or deleted, unless the user id is already in the array. The state field itself is then discarded
        4. the notification identified by id is merged with the updates and saved to database
        5. HTTP response code 204 is returned, unless there is error.
      • admin requests are allowed to update any field

    Delete a Notification

    This API is mainly used for marking an inApp notification deleted. It has the same effect as updating a notification with state set to deleted.

    DELETE /notifications/{id}
    +
    • permissions required, one of
      • super admin
      • admin
      • authenticated user
    • inputs
      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
    • outcome: same as the outcome of Update a Notification with state set to deleted.

    Replace a Notification

    PUT /notifications/{id}
    +

    This API is intended to be only used by admin web console to modify a notification in new state. Notifications in such state are typically future-dated or of channel in-app.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • notification data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC process the request same way as Create/Send Notifications except that notification data is saved with id supplied in the parameter, replacing existing one.

    `,12);function ee(te,se){const i=n("ExternalLinkIcon"),o=n("RouterLink");return l(),d("div",null,[u,e("ul",null,[m,e("li",null,[h,e("ul",null,[e("li",null,[f,e("ol",null,[b,e("li",null,[t("in the format supported by "),e("a",g,[t("qs"),a(i)]),t(", for example "),v])]),y,e("ul",null,[e("li",null,[t("for "),k,t(" , see MongoDB "),e("a",q,[t("Query Documents"),a(i)])]),e("li",null,[t("for "),_,t(" , see Mongoose "),e("a",w,[t("select"),a(i)])]),e("li",null,[t("for "),x,t(", see Mongoose "),e("a",I,[t("sort"),a(i)])]),e("li",null,[t("for "),B,t(", see MongoDB "),e("a",j,[t("cursor.skip"),a(i)])]),e("li",null,[t("for "),A,t(", see MongoDB "),e("a",C,[t("cursor.limit"),a(i)])])])])])]),T]),N,e("ul",null,[P,e("li",null,[S,e("ul",null,[e("li",null,[e("p",null,[t("a "),D,t(" query parameter with value conforming to MongoDB "),e("a",F,[t("Query Documents"),a(i)])]),E,R,e("ol",null,[L,e("li",null,[t("in the format supported by "),e("a",U,[t("qs"),a(i)]),t(", for example "),V])])])])]),M]),O,e("ul",null,[W,e("li",null,[$,G,e("ol",null,[H,e("li",null,[J,e("ol",null,[Q,e("li",null,[e("p",null,[t("the subscriptions are partitioned and processed concurrently as described in config section "),a(o,{to:"/docs/config-notification/#broadcast-push-notification-task-concurrency"},{default:p(()=>[t("Broadcast Push Notification Task Concurrency")]),_:1})])]),z]),X]),Z])]),K]),Y])}const ie=r(c,[["render",ee],["__file","index.html.vue"]]);export{ie as default}; diff --git a/preview/assets/index.html-42466a4a.js b/preview/assets/index.html-42466a4a.js new file mode 100644 index 000000000..110147f27 --- /dev/null +++ b/preview/assets/index.html-42466a4a.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-02a19d2b","path":"/docs/config-rsaKeys/","title":"RSA Keys","lang":"en-US","frontmatter":{"permalink":"/docs/config-rsaKeys/"},"headers":[],"git":{},"filePathRelative":"docs/config/rsaKeys.md"}');export{e as data}; diff --git a/preview/assets/index.html-43ad52c1.js b/preview/assets/index.html-43ad52c1.js new file mode 100644 index 000000000..60b2ac6b8 --- /dev/null +++ b/preview/assets/index.html-43ad52c1.js @@ -0,0 +1,3 @@ +import{_ as r,r as o,o as l,c,a as e,b as t,d as n,w as d,e as a}from"./app-73097456.js";const u={},h=a('

    Developer Notes

    Setup development environment

    Install Visual Studio Code and following extensions:

    • Prettier
    • ESLint
    • Vetur
    • Code Spell Checker
    • Debugger for Chrome

    Multiple run configs have been created to facilitate debugging server, client, test and docs.

    Client certificate authentication doesn't work in client debugger

    Because Vue cli webpack dev server cannot proxy passthrough HTTPS connections, client certificate authentication doesn't work in client debugger. If testing client certificate authentication in web console is needed, run npm run build to generate prod client distribution and launch server debugger on https://localhost:3000

    Automated Testing

    ',7),p=e("em",null,"NotifyBC",-1),m={href:"https://jestjs.io/",target:"_blank",rel:"noopener noreferrer"},b=e("code",null,"npm run test:e2e",-1),f=e("em",null,"Test",-1),g=e("p",null,"Github Actions runs tests as part of the build. All test scripts should be able to run unattended, headless, quickly and depend only on local resources.",-1),_=e("h3",{id:"writing-test-specs",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#writing-test-specs","aria-hidden":"true"},"#"),t(" Writing Test Specs")],-1),v={href:"https://github.com/visionmedia/supertest",target:"_blank",rel:"noopener noreferrer"},k={href:"https://github.com/nodkz/mongodb-memory-server",target:"_blank",rel:"noopener noreferrer"},w=e("em",null,"sendMail",-1),x=e("em",null,"sendSMS",-1),y=a(`
    • start at a processing phase as early as possible. For example, to test a REST end point, start with the HTTP user request.
    • assert outcome of a processing phase as late and down below as possible - the HTTP response body/code, the database record created, for example.
    • avoid asserting middleware function input/output to facilitate code refactoring.
    • mock email/sms sending function (implemented by default). Inspect the input of the function, or at least assert the function has been called.

    Install Docs Website

    If you want to contribute to NotifyBC docs beyond simple fix ups, run

    cd docs && npm install && npm run dev
    +

    If everything goes well, the last line of the output will be

    > VuePress dev server listening at http://localhost:8080/NotifyBC/
    +
    `,6),C={href:"http://localhost:8080/NotifyBC/",target:"_blank",rel:"noopener noreferrer"},T=e("h2",{id:"publish-version-checklist",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#publish-version-checklist","aria-hidden":"true"},"#"),t(" Publish Version Checklist")],-1),S=e("li",null,[t("update "),e("em",null,"version"),t(" in "),e("em",null,"package.json")],-1),N=e("li",null,[t("update "),e("em",null,"version"),t(),e("em",null,"appVersion"),t(" in "),e("em",null,"helm/Chart.yaml"),t(" (major/minor only)")],-1),V=e("li",null,"create a new Github release",-1);function B(I,P){const s=o("ExternalLinkIcon"),i=o("RouterLink");return l(),c("div",null,[h,e("p",null,[p,t(" uses "),e("a",m,[t("Jest"),n(s)]),t(" test framework bundled in NestJS. To launch test, run "),b,t(". A "),f,t(" launch config is provided to debug in VS Code.")]),g,_,e("p",null,[t("Thanks to "),e("a",v,[t("supertest"),n(s)]),t(" and "),e("a",k,[t("MongoDB In-Memory Server"),n(s)]),t(", test specs can be written to cover nearly end-to-end request processing workflow (only "),w,t(" and "),x,t(" need to be mocked). This allows test specs to anchor onto business requirements rather than program units such as functions or files, resulting in regression tests that are more resilient to code refactoring. Whenever possible, a test spec should be written to")]),y,e("p",null,[t("You can now browse to the local docs site "),e("a",C,[t("http://localhost:8080/NotifyBC"),n(s)])]),T,e("ol",null,[S,N,e("li",null,[t("update "),n(i,{to:"/docs/getting-started/what's-new.html"},{default:d(()=>[t("What's new")]),_:1}),t(" (major/minor only)")]),V])])}const E=r(u,[["render",B],["__file","index.html.vue"]]);export{E as default}; diff --git a/preview/assets/index.html-47b50680.js b/preview/assets/index.html-47b50680.js new file mode 100644 index 000000000..3bce7b989 --- /dev/null +++ b/preview/assets/index.html-47b50680.js @@ -0,0 +1,17 @@ +import{_ as t,r as o,o as p,c,a as n,b as s,d as i,e as a}from"./app-73097456.js";const l={},r=n("h1",{id:"oidc",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#oidc","aria-hidden":"true"},"#"),s(" OIDC")],-1),u=n("p",null,[n("em",null,"NotifyBC"),s(" currently can only authenticate RSA signed OIDC access token if the token is a JWT. OIDC providers such as Keycloak meet the requirement.")],-1),d=n("p",null,[s("To enable OIDC authentication strategy, add "),n("em",null,"oidc"),s(" configuration object to "),n("em",null,"/src/config.local.js"),s(". The object supports following properties")],-1),k=n("em",null,"discoveryUrl",-1),m={href:"https://openid.net/specs/openid-connect-discovery-1_0.html",target:"_blank",rel:"noopener noreferrer"},f=a("
  • clientId - OIDC client id
  • isAdmin - a predicate function to determine if authenticated user is NotifyBC administrator. The function takes the decoded OIDC access token JWT payload as input user object and should return either a boolean or a promise of boolean, i.e. the function can be both sync or async.
  • isAuthorizedUser - an optional predicate function to determine if authenticated user is an authorized NotifyBC user. If omitted, any authenticated user is authorized NotifyBC user. This function has same signature as isAdmin
  • ",3),h=a(`

    A example of complete OIDC configuration looks like

    module.exports = {
    +  ...
    +  oidc: {
    +    discoveryUrl:
    +      'https://op.example.com/auth/realms/foo/.well-known/openid-configuration',
    +    clientId: 'NotifyBC',
    +    isAdmin(user) {
    +      const roles = user.resource_access.NotifyBC.roles;
    +      if (!(roles instanceof Array) || roles.length === 0) return false;
    +      return roles.indexOf('admin') > -1;
    +    },
    +    isAuthorizedUser(user) {
    +      return user.realm_access.roles.indexOf('offline_access') > -1;
    +    },
    +  },
    +};
    +

    In NotifyBC web console and only in the web console, OIDC authentication takes precedence over built-in admin user, meaning if OIDC is configured, the login button goes to OIDC provider rather than the login form.

    There is no default OIDC configuration in /src/config.ts.

    `,4);function v(b,y){const e=o("ExternalLinkIcon");return p(),c("div",null,[r,u,d,n("ol",null,[n("li",null,[k,s(" - "),n("a",m,[s("OIDC discovery"),i(e)]),s(" url")]),f]),h])}const g=t(l,[["render",v],["__file","index.html.vue"]]);export{g as default}; diff --git a/preview/assets/index.html-4a529ad3.js b/preview/assets/index.html-4a529ad3.js new file mode 100644 index 000000000..a5bf55635 --- /dev/null +++ b/preview/assets/index.html-4a529ad3.js @@ -0,0 +1,47 @@ +import{_ as l,r as o,o as p,c as r,a as n,b as s,d as e,w as c,e as a}from"./app-73097456.js";const u={},d=a('

    Benchmarks

    tl;dr

    A NotifyBC server node can deliver 1 million emails in as little as 1 hour to a SMTP server node. SMTP server node's disk I/O is the bottleneck in such case. Throughput can be improved through horizontal scaling.

    When NotifyBC is used to deliver broadcast push notifications to a large number of subscribers, probably the most important benchmark is throughput. The benchmark is especially critical if a latency cap is desired. To facilitate capacity planning, load testing on the email channel has been conducted. The test environment, procedure, results and performance tuning advices are provided hereafter.

    Environment

    Hardware

    Two computers, connected by 1Gbps LAN, are used to host

    • NotifyBC
      • Mac Mini Late 2012 model
      • Intel core i7-3615QM
      • 16GB RAM
      • 2TB HDD
    • SMTP and mail delivery
      • Lenovo ThinkCentre M Series 2015 model
      • Intel core i5-3470
      • 8GB RAM
      • 256GB SSD

    Software Stack

    The test was performed in August 2017. Unless otherwise specified, the versions of all other software were reasonably up-to-date at the time of testing.

    • NotifyBC

      • MacOS Sierra Version 10.12.6
      • Virtualbox VM with 8vCPU, 10GB RAM, created using miniShift v1.3.1+f4900b07
      • OpenShift 1.5.1+7b451fc with metrics
      • default NotifyBC OpenShift installation, which contains following relevant pods
        • 1 mongodb pod with 1 core, 1GiB RAM limit
        • a variable number of Node.js app pods each with 1 core, 1GiB RAM limit. The number varies by test runs as indicated in result.
    • SMTP and mail delivery

      • Windows 7 host
      • Virtualbox VM with 4 vCPU, 3.5GB RAM, running Windows Server 2012
      • added SMTP Server feature
      • in SMTP Server properties dialog box, uncheck all of following boxes in Messages tab
        • Limit message size to (KB)
        • Limit session size to (KB)
        • Limit number of messages per connection to
        • Limit number of recipients per message to

    Procedure

    ',11),h=n("em",null,"/src/config.local.js",-1),m=a(`
    var _ = require('lodash');
    +module.exports = {
    +  smtp: {
    +    host: '<smtp-vm-ip-or-hostname>',
    +    secure: false,
    +    port: 25,
    +    pool: true,
    +    direct: false,
    +    maxMessages: Infinity,
    +    maxConnections: 50,
    +  },
    +  notification: {
    +    broadcastCustomFilterFunctions: {
    +      /*jshint camelcase: false */
    +      contains_ci: {
    +        _func: function (resolvedArgs) {
    +          if (!resolvedArgs[0] || !resolvedArgs[1]) {
    +            return false;
    +          }
    +          return (
    +            _.toLower(resolvedArgs[0]).indexOf(_.toLower(resolvedArgs[1])) >=
    +            0
    +          );
    +        },
    +        _signature: [
    +          {
    +            types: [2],
    +          },
    +          {
    +            types: [2],
    +          },
    +        ],
    +      },
    +    },
    +  },
    +};
    +
    `,1),k={href:"https://github.com/bcgov/NotifyBC/blob/main/src/utils/load-testing/bulk-post-subs.ts",target:"_blank",rel:"noopener noreferrer"},v=n("em",null,"load10",-1),b=n("em",null,"load1000000",-1),g=n("em",null,"bulk-post-subs.js",-1),f=n("em",null,"userChannelId",-1),y=a(`
    $ node dist/utils/load-testing/bulk-post-subs.js -h
    +Usage: node bulk-post-subs.js [Options] <userChannelId>
    +[Options]:
    +-a, --api-url-prefix=<string>                      api url prefix. default to http://localhost:3000/api
    +-c, --channel=<string>                             channel. default to email
    +-s, --service-name=<string>                        service name. default to load
    +-n, --number-of-subscribers=<int>                  number of subscribers. positive integer. default to 1000
    +-f, --broadcast-push-notification-filter=<string>  broadcast push notification filter. default to "contains_ci(title,'vancouver') || contains_ci(title,'victoria')"
    +-h, --help                                         display this help
    +

    The generated subscriptions contain a filter, hence all load testing results below included time spent on filtering.

    `,2),_={href:"https://github.com/bcgov/NotifyBC/blob/main/src/utils/load-testing/curl-ntf.sh",target:"_blank",rel:"noopener noreferrer"},x=n("div",{class:"language-text line-numbers-mode","data-ext":"text"},[n("pre",{class:"language-text"},[n("code",null,`dist/utils/load-testing/curl-ntf.sh +`)]),n("div",{class:"line-numbers","aria-hidden":"true"},[n("div",{class:"line-number"})])],-1),w=n("p",null,"The script will print start time and the time taken to dispatch the notification.",-1),M=a('

    Results

    email counttime taken (min)throughput (#/min)app pod countnotes on bottleneck
    1,000,00071.513,9861app pod cpu capped
    100,0005.817,2412smtp vm disk queue length hits 1 frequently
    1,000,0005717,5442smtp vm disk queue length hits 1 frequently
    1,000,00057.817,3013smtp vm disk queue length hits 1 frequently

    Test runs using other software or configurations described below have also been conducted. Because throughput is significantly lower, results are not shown

    • using Linux sendmail SMTP. The throughput of a 4-vCPU Linux VM is about the same as a 1-vCPU Windows SMTP server. Bottleneck in such case is the CPU of SMTP server.
    • Reducing NotifyBC app pod's resource limit to 100 millicore CPU and 512MiB RAM. Even when scaled up pod count to 15, throughput is still about 1/3 of a 1-core pod.

    Here is a sample email saved onto the mail drop folder of SMTP server.

    Comparison to Other Benchmarks

    ',6),S={href:"https://technet.microsoft.com/en-us/library/bb124213(v=exchg.65).aspx",target:"_blank",rel:"noopener noreferrer"},T=n("em",null,"NotifyBC",-1),P=n("ol",null,[n("li",null,"Email size in Microsoft's load test is 50k, as opposed to 1k used in this test"),n("li",null,"SSD storage is used in this test. It is unlikely the test conducted in 2005 used SSD.")],-1),B=n("h2",{id:"advices",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#advices","aria-hidden":"true"},"#"),s(" Advices")],-1),C=n("li",null,"Avoid using default direct mode in production. Instead use SMTP server. Direct mode doesn't support connection pooling, resulting in port depletion quickly.",-1),A={href:"https://nodemailer.com/smtp/pooled/",target:"_blank",rel:"noopener noreferrer"},N=a("
  • Set smtp config maxConnections to a number big enough as long as SMTP server can handle. Test found for Windows SMTP server 50 is a suitable number, beyond which performance gain is insignificant.
  • Set smtp config maxMessages to maximum possible number allowed by your SMTP server, or Infinity if SMTP server imposes no such constraint
  • Avoid setting CPU resource limit too low for NotifyBC app pods.
  • If you have control over the SMTP server,
    • use SSD for its storage
    • create a load balanced cluster if possible, since SMTP server is more likely to be the bottleneck.
  • ",4);function L(I,R){const i=o("RouterLink"),t=o("ExternalLinkIcon");return p(),r("div",null,[d,n("ol",null,[n("li",null,[n("p",null,[s("update or create file "),h,s(" through "),e(i,{to:"/docs/installation/#update-configuration-files"},{default:c(()=>[s("configMap")]),_:1}),s(". Add sections for SMTP server and a custom filter function")]),m]),n("li",null,[n("p",null,[s("create a number of subscriptions in bulk using script "),n("a",k,[s("bulk-post-subs.js"),e(t)]),s(". To load test different email volumes, you can create bulk subscriptions in different services. For example, generate 10 subscriptions under service named "),v,s("; 1,000,000 subscriptions under service "),b,s(" etc. "),g,s(" takes "),f,s(" and other optional parameters")]),y]),n("li",null,[n("p",null,[s("launch load testing using script "),n("a",_,[s("curl-ntf.sh"),e(t)]),s(", which takes following optional parameters")]),x,w])]),M,n("p",null,[s("According to "),n("a",S,[s("Baseline Performance for SMTP"),e(t)]),s(" published on Microsoft Technet in 2005, Windows SMTP server has a max throughput of 142 emails/s. However this "),T,s(" load test yields a max throughput of 292 emails/s. The discrepancy may be attributed to following factors")]),P,B,n("ul",null,[C,n("li",null,[s("Enable SMTP "),n("a",A,[s("pooling"),e(t)]),s(".")]),N])])}const q=l(u,[["render",L],["__file","index.html.vue"]]);export{q as default}; diff --git a/preview/assets/index.html-4edd1034.js b/preview/assets/index.html-4edd1034.js new file mode 100644 index 000000000..f6af67479 --- /dev/null +++ b/preview/assets/index.html-4edd1034.js @@ -0,0 +1 @@ +import{_ as n,r as l,o as s,c as a,a as e,b as r,d as o}from"./app-73097456.js";const i={},_=e("h1",{id:"acknowledgments",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#acknowledgments","aria-hidden":"true"},"#"),r(" Acknowledgments")],-1),c=e("p",null,[e("em",null,"NotifyBC"),r(" is built on a myriad of open source software. At runtime it also depends on a few services. Credit goes to their contributors. Notably")],-1),h={href:"https://nodejs.org/",target:"_blank",rel:"noopener noreferrer"},d={href:"https://nestjs.com/",target:"_blank",rel:"noopener noreferrer"},f={href:"https://www.mongodb.com/",target:"_blank",rel:"noopener noreferrer"},p={href:"https://nodemailer.com/",target:"_blank",rel:"noopener noreferrer"},u={href:"https://jmespath.org/",target:"_blank",rel:"noopener noreferrer"},m={href:"https://vuejs.org/",target:"_blank",rel:"noopener noreferrer"},g={href:"https://vuetifyjs.com/",target:"_blank",rel:"noopener noreferrer"},b={href:"https://github.com/json-editor/json-editor",target:"_blank",rel:"noopener noreferrer"},w={href:"https://vuepress.vuejs.org/",target:"_blank",rel:"noopener noreferrer"},k={href:"https://www.twilio.com/",target:"_blank",rel:"noopener noreferrer"},j={href:"https://www.swiftsmsgateway.com/",target:"_blank",rel:"noopener noreferrer"},x={href:"https://bitnami.com/",target:"_blank",rel:"noopener noreferrer"};function N(v,y){const t=l("ExternalLinkIcon");return s(),a("div",null,[_,c,e("ul",null,[e("li",null,[e("a",h,[r("Node.js"),o(t)])]),e("li",null,[e("a",d,[r("NestJS"),o(t)])]),e("li",null,[e("a",f,[r("MongoDB"),o(t)])]),e("li",null,[e("a",p,[r("NodeMailer"),o(t)])]),e("li",null,[e("a",u,[r("JMESPath"),o(t)])]),e("li",null,[e("a",m,[r("Vue"),o(t)])]),e("li",null,[e("a",g,[r("Vuetify"),o(t)])]),e("li",null,[e("a",b,[r("JSON Editor"),o(t)])]),e("li",null,[e("a",w,[r("VuePress"),o(t)])]),e("li",null,[e("a",k,[r("Twilio"),o(t)])]),e("li",null,[e("a",j,[r("Swift SMS Gateway"),o(t)])]),e("li",null,[e("a",x,[r("Bitnami"),o(t)])])])])}const S=n(i,[["render",N],["__file","index.html.vue"]]);export{S as default}; diff --git a/preview/assets/index.html-4f4b887a.js b/preview/assets/index.html-4f4b887a.js new file mode 100644 index 000000000..e19c25658 --- /dev/null +++ b/preview/assets/index.html-4f4b887a.js @@ -0,0 +1 @@ +import{_ as e,o as t,c as o,e as r}from"./app-73097456.js";const n={},s=r('

    Worker Process Count

    When NotifyBC runs on a host with multiple CPUs, by default it creates a cluster of worker processes of which the count matches CPU count. You can override the number with the environment variable NOTIFYBC_WORKER_PROCESS_COUNT.

    A note about worker process count on OpenShift

    It has been observed that on OpenShift Node.js returns incorrect CPU count. The template therefore sets NOTIFYBC_WORKER_PROCESS_COUNT to 1. After all, on OpenShift NotifyBC is expected to be horizontally scaled by pods rather by CPUs.

    ',3),c=[s];function a(i,h){return t(),o("div",null,c)}const d=e(n,[["render",a],["__file","index.html.vue"]]);export{d as default}; diff --git a/preview/assets/index.html-549ff1f9.js b/preview/assets/index.html-549ff1f9.js new file mode 100644 index 000000000..617241f3a --- /dev/null +++ b/preview/assets/index.html-549ff1f9.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-1963670f","path":"/docs/config-httpHost/","title":"HTTP Host","lang":"en-US","frontmatter":{"permalink":"/docs/config-httpHost/"},"headers":[],"git":{},"filePathRelative":"docs/config/httpHost.md"}');export{t as data}; diff --git a/preview/assets/index.html-568e376b.js b/preview/assets/index.html-568e376b.js new file mode 100644 index 000000000..4dd92e248 --- /dev/null +++ b/preview/assets/index.html-568e376b.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-b09aba04","path":"/docs/benchmarks/","title":"Benchmarks","lang":"en-US","frontmatter":{"permalink":"/docs/benchmarks/","prev":"/docs/api-bounce/"},"headers":[{"level":2,"title":"Environment","slug":"environment","link":"#environment","children":[{"level":3,"title":"Hardware","slug":"hardware","link":"#hardware","children":[]},{"level":3,"title":"Software Stack","slug":"software-stack","link":"#software-stack","children":[]}]},{"level":2,"title":"Procedure","slug":"procedure","link":"#procedure","children":[]},{"level":2,"title":"Results","slug":"results","link":"#results","children":[{"level":3,"title":"Comparison to Other Benchmarks","slug":"comparison-to-other-benchmarks","link":"#comparison-to-other-benchmarks","children":[]}]},{"level":2,"title":"Advices","slug":"advices","link":"#advices","children":[]}],"git":{},"filePathRelative":"docs/miscellaneous/benchmarks.md"}');export{e as data}; diff --git a/preview/assets/index.html-585cce7b.js b/preview/assets/index.html-585cce7b.js new file mode 100644 index 000000000..f3864fd40 --- /dev/null +++ b/preview/assets/index.html-585cce7b.js @@ -0,0 +1 @@ +import{_ as o,r as i,o as s,c as r,a as t,b as e,u as c,d as l,e as h,f as d}from"./app-73097456.js";const u=h('

    Welcome

    This site aims to be a comprehensive guide to NotifyBC. We’ll cover topics such as getting your instance up and running, interacting with browser or other server components, deployment, and give you some advice on participating in the future development of NotifyBC itself.

    Helpful Hints

    Throughout this guide there are a number of small-but-handy pieces of information that can make using NotifyBC easier, more interesting, and less hazardous. Here’s what to look out for.

    General information

    These are tips and tricks that will help you become a NotifyBC wizard!

    Important information

    These are tidbits you might want to keep in mind.

    Warnings

    Be aware of these messages if you wish to avoid disaster.

    ',7),p=["href"],m={__name:"index.html",setup(f){const a=d();return(g,_)=>{const n=i("ExternalLinkIcon");return s(),r("div",null,[u,t("p",null,[e("If you come across anything along the way that we haven’t covered, or if you know of a tip you think others would find handy, please "),t("a",{target:"_blank",rel:"noopener noreferrer",href:c(a).repo+"/issues/new"},[e("file an issue"),l(n)],8,p),e(" and we’ll see about including it in this guide.")])])}}},w=o(m,[["__file","index.html.vue"]]);export{w as default}; diff --git a/preview/assets/index.html-59271dfc.js b/preview/assets/index.html-59271dfc.js new file mode 100644 index 000000000..d7d6e5777 --- /dev/null +++ b/preview/assets/index.html-59271dfc.js @@ -0,0 +1 @@ +import{_ as r,r as s,o as a,c as d,a as e,b as t,d as o,w as h}from"./app-73097456.js";const c={},_=e("h2",{id:"getting-help",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#getting-help","aria-hidden":"true"},"#"),t(" Getting Help")],-1),u=e("p",null,"Need help with NotifyBC? Try these resources.",-1),l={id:"documentation",tabindex:"-1"},f=e("a",{class:"header-anchor",href:"#documentation","aria-hidden":"true"},"#",-1),g=e("p",null,"Our guide to NotifyBC covering installation, writing, customization, deployment, and more.",-1),p={id:"view-source",tabindex:"-1"},m=e("a",{class:"header-anchor",href:"#view-source","aria-hidden":"true"},"#",-1),b={href:"https://github.com/bcgov/NotifyBC",target:"_blank",rel:"noopener noreferrer"},x=e("p",null,"Use the source, Luke.",-1),y={id:"google",tabindex:"-1"},w=e("a",{class:"header-anchor",href:"#google","aria-hidden":"true"},"#",-1),k={href:"https://www.google.com/?q=NotifyBC",target:"_blank",rel:"noopener noreferrer"},N=e("p",null,[t("Add "),e("strong",null,"NotifyBC"),t(" to almost any query, and you'll find just what you need.")],-1),v={id:"outstanding-issues-and-requests",tabindex:"-1"},B=e("a",{class:"header-anchor",href:"#outstanding-issues-and-requests","aria-hidden":"true"},"#",-1),C={href:"https://github.com/bcgov/NotifyBC/issues",target:"_blank",rel:"noopener noreferrer"},q=e("p",null,"Search through the issues on the main NotifyBC development. Think you've found a bug? File a new issue.",-1);function L(V,E){const i=s("RouterLink"),n=s("ExternalLinkIcon");return a(),d("div",null,[_,u,e("h3",l,[f,t(),o(i,{to:"/docs/"},{default:h(()=>[t("Documentation")]),_:1})]),g,e("h3",p,[m,t(),e("a",b,[t("View source"),o(n)])]),x,e("h3",y,[w,t(),e("a",k,[t("Google"),o(n)])]),N,e("h3",v,[B,t(),e("a",C,[t("Outstanding issues and requests"),o(n)])]),q])}const G=r(c,[["render",L],["__file","index.html.vue"]]);export{G as default}; diff --git a/preview/assets/index.html-5afa062f.js b/preview/assets/index.html-5afa062f.js new file mode 100644 index 000000000..c7b02e55f --- /dev/null +++ b/preview/assets/index.html-5afa062f.js @@ -0,0 +1,74 @@ +import{_ as i,r as p,o as l,c as r,a as n,b as s,d as a,w as c,e as t}from"./app-73097456.js";const u={},d=n("h1",{id:"sms",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#sms","aria-hidden":"true"},"#"),s(" SMS")],-1),m=n("h2",{id:"provider",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#provider","aria-hidden":"true"},"#"),s(" Provider")],-1),k=n("p",null,[n("em",null,"NotifyBC"),s(" depends on underlying SMS service providers to deliver SMS messages. The supported service providers are")],-1),v={href:"https://twilio.com/",target:"_blank",rel:"noopener noreferrer"},b={href:"https://www.swiftsmsgateway.com",target:"_blank",rel:"noopener noreferrer"},h=t(`

    Only one service provider can be chosen per installation. To change service provider, add following config to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    provider: 'swift',
    +  },
    +};
    +

    Provider Settings

    Provider specific settings are defined in config sms.providerSettings. You should have an account with the chosen service provider before proceeding.

    Twilio

    Add sms.providerSettings.twilio config object to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    providerSettings: {
    +      twilio: {
    +        accountSid: '<AccountSid>',
    +        authToken: '<AuthToken>',
    +        fromNumber: '<FromNumber>',
    +      },
    +    },
    +  },
    +};
    +

    Obtain <AccountSid>, <AuthToken> and <FromNumber> from your Twilio account.

    Swift

    Add sms.providerSettings.swift config object to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    providerSettings: {
    +      swift: {
    +        accountKey: '<accountKey>',
    +      },
    +    },
    +  },
    +};
    +

    Obtain <accountKey> from your Swift account.

    Unsubscription by replying a keyword

    With Swift short code, sms user can unsubscribe by replying to a sms message with a keyword. The keyword must be pre-registered with Swift.

    To enable this feature,

    `,15),g=t(`
  • Generate a random string, hereafter referred to as <randomly-generated-string>

  • Add it to sms.providerSettings.swift.notifyBCSwiftKey in file /src/config.local.js

    module.exports = {
    +  sms: {
    +    providerSettings: {
    +      swift: {
    +        notifyBCSwiftKey: '<randomly-generated-string>',
    +      },
    +    },
    +  },
    +};
    +
  • Go to Swift web admin console, click Number Management tab

  • Click Launch button next to Manage Short Code Keywords

  • Click Features button next to the registered keyword(s). A keyword may have multiple entries. In such case do this for each entry.

  • Click Redirect To Webpage tab in the popup window

  • `,6),y=n("p",null,"Enter following information in the tab",-1),f=n("em",null,"URL",-1),_=n("em",null,"/api/subscriptions/swift",-1),w=n("em",null,"",-1),x=t("
  • set Method to POST
  • set Custom Parameter 1 Name to notifyBCSwiftKey
  • set Custom Parameter 1 Value to <randomly-generated-string>
  • ",3),S=n("li",null,[n("p",null,[s("Click "),n("em",null,"Save Changes"),s(" button and then "),n("em",null,"Done")])],-1),j=t(`

    Throttle

    All supported SMS service providers impose request rate limit. NotifyBC by default throttles request rate to 4/sec. To adjust the rate, create following config in file /src/config.local.js

    module.exports = {
    +  sms: {
    +    throttle: {
    +      // minimum request interval in ms
    +      minTime: 250,
    +    },
    +  },
    +};
    +

    When NotifyBC is deployed from source code, by default the rate limit applies to one Node.js process only. If there are multiple processes, i.e. a cluster, the aggregated rate limit is multiplied by the number of processes. To enforce the rate limit across entire cluster, install Redis and add Redis config to sms.throttle

    module.exports = {
    +  sms: {
    +    throttle: {
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        host: '127.0.0.1',
    +        port: 6379,
    +      },
    +    },
    +  },
    +};
    +

    If you installed Redis Sentinel,

    module.exports = {
    +  sms: {
    +    throttle: {
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        name: 'mymaster',
    +        sentinels: [{ host: '127.0.0.1', port: 26379 }],
    +      },
    +    },
    +  },
    +};
    +
    `,7),T={href:"https://github.com/SGrondin/bottleneck",target:"_blank",rel:"noopener noreferrer"},C={href:"https://github.com/luin/ioredis",target:"_blank",rel:"noopener noreferrer"},N=n("em",null,"NotifyBC",-1),B=n("em",null,"jobExpiration",-1),R=n("em",null,"expiration",-1),A={href:"https://github.com/bcgov/NotifyBC/blob/main/src/config.ts",target:"_blank",rel:"noopener noreferrer"},H=t(`

    When NotifyBC is deployed to Kubernetes using Helm, by default throttle, if enabled, uses Redis Sentinel therefore rate limit applies to whole cluster.

    To disable throttle, set sms.throttle.enabled to false in file /src/config.local.js

    module.exports = {
    +  sms: {
    +    throttle: {
    +      enabled: false,
    +    },
    +  },
    +};
    +
    `,3);function K(P,M){const e=p("ExternalLinkIcon"),o=p("RouterLink");return l(),r("div",null,[d,m,k,n("ul",null,[n("li",null,[n("a",v,[s("Twilio"),a(e)]),s(" (default)")]),n("li",null,[n("a",b,[s("Swift"),a(e)])])]),h,n("ol",null,[g,n("li",null,[y,n("ul",null,[n("li",null,[s("set "),f,s(" to "),_,s(", where "),w,s(" is NotifyBC HTTP host name and should be the same as "),a(o,{to:"/docs/config-httpHost/"},{default:c(()=>[s("HTTP Host")]),_:1}),s(" config")]),x])]),S]),j,n("p",null,[s("Throttle is implemented using "),n("a",T,[s("Bottleneck"),a(e)]),s(" and "),n("a",C,[s("ioredis"),a(e)]),s(". See their documentations for more configurations. The only deviation made by "),N,s(" is using "),B,s(" to denote Bottleneck "),R,s(" job option with a default value of 2min as defined in "),n("a",A,[s("config.ts"),a(e)]),s(".")]),H])}const O=i(u,[["render",K],["__file","index.html.vue"]]);export{O as default}; diff --git a/preview/assets/index.html-5b46e0a4.js b/preview/assets/index.html-5b46e0a4.js new file mode 100644 index 000000000..acd133a56 --- /dev/null +++ b/preview/assets/index.html-5b46e0a4.js @@ -0,0 +1 @@ +import{_ as i,r,o as a,c as s,a as t,b as e,d as n,e as c}from"./app-73097456.js";const l={},p=c('

    Code of Conduct

    As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.

    We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality.

    Examples of unacceptable behavior by participants include:

    • The use of sexualized language or imagery
    • Personal attacks
    • Trolling or insulting/derogatory comments
    • Public or private harassment
    • Publishing other's private information, such as physical or electronic addresses, without explicit permission
    • Other unethical or unprofessional conduct

    Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

    By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team.

    This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community.

    Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting a project maintainer. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident.

    ',9),d={href:"http://contributor-covenant.org",target:"_blank",rel:"noopener noreferrer"},h={href:"http://contributor-covenant.org/version/1/3/0/",target:"_blank",rel:"noopener noreferrer"};function u(m,f){const o=r("ExternalLinkIcon");return a(),s("div",null,[p,t("p",null,[e("This Code of Conduct is adapted from the "),t("a",d,[e("Contributor Covenant"),n(o)]),e(", version 1.3.0, available at "),t("a",h,[e("http://contributor-covenant.org/version/1/3/0/"),n(o)])])])}const b=i(l,[["render",u],["__file","index.html.vue"]]);export{b as default}; diff --git a/preview/assets/index.html-602c2540.js b/preview/assets/index.html-602c2540.js new file mode 100644 index 000000000..08190bfaf --- /dev/null +++ b/preview/assets/index.html-602c2540.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-9712b6e4","path":"/docs/upgrade/","title":"Upgrade Guide","lang":"en-US","frontmatter":{"permalink":"/docs/upgrade/","next":"/docs/conduct/"},"headers":[{"level":2,"title":"v1 to v2","slug":"v1-to-v2","link":"#v1-to-v2","children":[{"level":3,"title":"Update your client code","slug":"update-your-client-code","link":"#update-your-client-code","children":[]},{"level":3,"title":"Upgrade NotifyBC server","slug":"upgrade-notifybc-server","link":"#upgrade-notifybc-server","children":[]}]},{"level":2,"title":"OpenShift template to Helm","slug":"openshift-template-to-helm","link":"#openshift-template-to-helm","children":[{"level":3,"title":"Customize and install Helm chart","slug":"customize-and-install-helm-chart","link":"#customize-and-install-helm-chart","children":[]},{"level":3,"title":"Migrate MongoDB data","slug":"migrate-mongodb-data","link":"#migrate-mongodb-data","children":[]}]},{"level":2,"title":"v2 to v3","slug":"v2-to-v3","link":"#v2-to-v3","children":[]},{"level":2,"title":"v3 to v4","slug":"v3-to-v4","link":"#v3-to-v4","children":[]},{"level":2,"title":"v4 to v5","slug":"v4-to-v5","link":"#v4-to-v5","children":[]}],"git":{},"filePathRelative":"docs/miscellaneous/upgrade.md"}');export{e as data}; diff --git a/preview/assets/index.html-671e4912.js b/preview/assets/index.html-671e4912.js new file mode 100644 index 000000000..75fb7102f --- /dev/null +++ b/preview/assets/index.html-671e4912.js @@ -0,0 +1,72 @@ +import{_ as c,r as p,o as r,c as l,a as n,b as s,d as e,w as i,e as t}from"./app-73097456.js";const u={},d=t('

    Notification

    Configs in this section customize the handling of notification request or generating notifications from RSS feeds. They are all sub-properties of config object notification. Service-agnostic configs are static and service-dependent configs are dynamic.

    RSS Feeds

    NotifyBC can generate broadcast push notifications automatically by polling RSS feeds periodically and detect changes by comparing with an internally maintained history list. The polling frequency, RSS url, RSS item change detection criteria, and message template can be defined in dynamic configs.

    Only first page is retrieved for paginated RSS feeds

    If a RSS feed is paginated, NotifyBC only retrieves the first page rather than auto-fetch subsequent pages. Hence paginated RSS feeds should be sorted descendingly by last modified timestamp. Refresh interval should be adjusted small enough such that all new or updated items are contained in first page.

    ',5),m=n("em",null,"myService",-1),h=n("em",null,"http://my-serivce/rss",-1),b=t(`
    {
    +  "name": "notification",
    +  "serviceName": "myService",
    +  "value": {
    +    "rss": {
    +      "url": "http://my-serivce/rss",
    +      "timeSpec": "* * * * *",
    +      "itemKeyField": "guid",
    +      "outdatedItemRetentionGenerations": 1,
    +      "includeUpdatedItems": true,
    +      "fieldsToCheckForUpdate": ["title", "pubDate", "description"]
    +    },
    +    "httpHost": "http://localhost:3000",
    +    "messageTemplates": {
    +      "email": {
    +        "from": "no_reply@invlid.local",
    +        "subject": "{title}",
    +        "textBody": "{description}",
    +        "htmlBody": "{description}"
    +      },
    +      "sms": {
    +        "textBody": "{description}"
    +      }
    +    }
    +  }
    +}
    +

    The config items in the value field are

    `,2),f=n("li",null,"url: RSS url",-1),k=n("a",{name:"timeSpec"},null,-1),v={href:"https://www.freebsd.org/cgi/man.cgi?crontab(5)",target:"_blank",rel:"noopener noreferrer"},g={href:"https://github.com/kelektiv/node-cron#cron-ranges",target:"_blank",rel:"noopener noreferrer"},y=t("
  • itemKeyField: rss item's unique key field to identify new items. By default guid
  • outdatedItemRetentionGenerations: number of last consecutive polls from which results an item has to be absent before the item can be removed from the history list. This config is designed to prevent multiple notifications triggered by the same item because RSS poll returns inconsistent results, usually due to a combination of pagination and lack of sorting. By default 1, meaning the history list only keeps the last poll result
  • includeUpdatedItems: whether to notify also updated items or just new items. By default false
  • fieldsToCheckForUpdate: list of fields to check for updates if includeUpdatedItems is true. By default ["pubDate"]
  • ",4),q=n("em",null,"NotifyBC",-1),_=t(`

    Broadcast Push Notification Task Concurrency

    To achieve horizontal scaling, when a broadcast push notification request, hereby known as original request, is received, NotifyBC divides subscribers into chunks and generates a HTTP sub-request for each chunk. The original request supervises the execution of sub-requests. The chunk size is defined by config broadcastSubscriberChunkSize. All subscribers in a sub-request chunk are processed concurrently when the sub-requests are submitted.

    The original request submits sub-requests back to (preferably load-balanced) NotifyBC server cluster for processing. Sub-request submission is throttled by config broadcastSubRequestBatchSize. broadcastSubRequestBatchSize defines the upper limit of the number of Sub-requests that can be processed at any given time.

    As an example, assuming the total number of subscribers for a notification is 1,000,000, broadcastSubscriberChunkSize is 1,000 and broadcastSubRequestBatchSize is 10, NotifyBC will divide the 1M subscribers into 1,000 chunks and generates 1,000 sub-requests, one for each chunk. The 1,000 sub-requests will be submitted back to NotifyBC cluster to be processed. The original request will ensure at most 10 sub-requests are submitted and being processed at any given time. In fact, the only time concurrency is less than 10 is near the end of the task when remaining sub-requests is less than 10. When a sub-request is received by NotifyBC cluster, all 1,000 subscribers are processed concurrently. Suppose each sub-request (i.e. 1,000 subscribers) takes 1 minute to process on average, then the total time to dispatch notifications to 1M subscribers takes 1,000/10 = 100min, or 1hr40min.

    The default value for broadcastSubscriberChunkSize and broadcastSubRequestBatchSize are defined in /src/config.ts

    module.exports = {
    +  notification: {
    +    broadcastSubscriberChunkSize: 1000,
    +    broadcastSubRequestBatchSize: 10,
    +  },
    +};
    +

    To customize, create the config with updated value in file /src/config.local.js.

    If total number of subscribers is less than broadcastSubscriberChunkSize, then no sub-requests are spawned. Instead, the main request dispatches all notifications.

    How to determine the optimal value for broadcastSubscriberChunkSize and broadcastSubRequestBatchSize?

    broadcastSubscriberChunkSize is determined by the concurrency capability of the downstream message handlers such as SMTP server or SMS service provider. broadcastSubRequestBatchSize is determined by the size of NotifyBC cluster. As a rule of thumb, set broadcastSubRequestBatchSize equal to the number of non-master nodes in NotifyBC cluster.

    Broadcast Push Notification Custom Filter Functions

    Advanced Topic

    Defining custom function requires knowledge of JavaScript and understanding how external libraries are added and referenced in Node.js. Setting a development environment to test the custom function is also recommended.

    `,11),S=n("em",null,"NotifyBC",-1),w={href:"https://github.com/f-w/jmespath.js",target:"_blank",rel:"noopener noreferrer"},B={href:"http://jmespath.org/",target:"_blank",rel:"noopener noreferrer"},j=n("a",{href:"../api-subscription#broadcastPushNotificationFilter"},"broadcastPushNotificationFilter",-1),x=n("a",{href:"../api-notification#broadcastPushNotificationSubscriptionFilter"},"broadcastPushNotificationSubscriptionFilter",-1),N=n("em",null,"notification.broadcastCustomFilterFunctions",-1),C=n("em",null,"async",-1),T=n("em",null,"contains_ci",-1),P=n("em",null,"/src/config.local.js",-1),R=t(`
    const _ = require('lodash')
    +module.exports = {
    +  ...
    +  notification: {
    +    broadcastCustomFilterFunctions: {
    +      contains_ci: {
    +        _func: async function(resolvedArgs) {
    +          if (!resolvedArgs[0] || !resolvedArgs[1]) {
    +            return false
    +          }
    +          return _.toLower(resolvedArgs[0]).indexOf(_.toLower(resolvedArgs[1])) >= 0
    +        },
    +        _signature: [
    +          {
    +            types: [2]
    +          },
    +          {
    +            types: [2]
    +          }
    +        ]
    +      }
    +    }
    +  }
    +}
    +
    `,1),F={href:"https://github.com/f-w/jmespath.js/blob/master/jmespath.js#L1127",target:"_blank",rel:"noopener noreferrer"},z={href:"https://github.com/f-w/jmespath.js/blob/master/jmespath.js#L132",target:"_blank",rel:"noopener noreferrer"},I={href:"https://lodash.com/",target:"_blank",rel:"noopener noreferrer"},A=t('

    install additional Node.js modules

    The recommended way to install additional Node.js modules is by running command npm install <your_module> from the directory one level above NotifyBC root. For example, if NotifyBC is installed on /data/notifyBC, then run the command from directory /data. The command will then install the module to /data/node_modules/<your_module>.

    Guaranteed Broadcast Push Dispatch Processing

    As a major enhancement in v3, by default NotifyBC guarantees all subscribers of a broadcast push notification will be processed in spite of NotifyBC node failures during dispatching. Node failure is a concern because the time takes to dispatch broadcast push notification is proportional to number of subscribers, which is potentially large.

    The guarantee is achieved by

    1. logging the dispatch result to database individually right after each dispatch
    2. when subscribers are divided into chunks and a chunk sub-request fails, the original request re-submits the sub-request
    3. the original request periodically updates the notification updated timestamp field as heartbeat during dispatching
    4. if original request fails,
      1. a cron job detects the failure from the stale timestamp, and re-submits the original request
      2. all chunk sub-requests detects the the failure from the socket error, and stop processing
    ',5),D=t(`

    If performance is a higher priority to you, disable both the guarantee and bounce handling by setting config notification.guaranteedBroadcastPushDispatchProcessing and email.bounce.enabled to false in file /src/config.local.js

    module.exports = {
    +  notification: {
    +    guaranteedBroadcastPushDispatchProcessing: false,
    +  },
    +  email: {
    +    bounce: {enabled: false},
    +  },
    +};
    +

    In such case only failed dispatches are written to dispatch.failed field of the notification.

    Also log skipped dispatches for broadcast push notifications

    When guaranteedBroadcastPushDispatchProcessing is true, by default only successful and failed dispatches are logged, along with dispatch candidates. Dispatches that are skipped by filters defined at subscription (broadcastPushNotificationFilter) or notification (broadcastPushNotificationSubscriptionFilter) are not logged for performance reason. If you also want skipped dispatches to be logged to dispatch.skipped field of the notification, set logSkippedBroadcastPushDispatches to true in file /src/config.local.js

    module.exports = {
    +  ...
    +  notification: {
    +    ...
    +    logSkippedBroadcastPushDispatches: true,
    +  }
    +}
    +

    Setting logSkippedBroadcastPushDispatches to true only has effect when guaranteedBroadcastPushDispatchProcessing is true.

    `,7);function L(M,H){const o=p("RouterLink"),a=p("ExternalLinkIcon");return r(),l("div",null,[d,n("p",null,[s("For example, to notify subscribers of "),m,s(" on updates to feed "),h,s(", create following config item using "),e(o,{to:"/docs/api-config/#create-a-configuration"},{default:i(()=>[s("POST configuration API")]),_:1})]),b,n("ul",null,[n("li",null,[s("rss "),n("ul",null,[f,n("li",null,[k,s("timeSpec: RSS poll frequency, a space separated fields conformed to "),n("a",v,[s("unix crontab format"),e(a)]),s(" with an optional left-most seconds field. See "),n("a",g,[s("allowed ranges"),e(a)]),s(" of each field")]),y])]),n("li",null,[s("httpHost: the http protocol, host and port used by "),e(o,{to:"/docs/overview/#mail-merge"},{default:i(()=>[s("mail merge")]),_:1}),s(". If missing, the value is auto-populated based on the REST request that creates this config item.")]),n("li",null,[s("messageTemplates: channel-specific message templates with channel name as the key. "),q,s(" generates a notification for each channel specified in the message templates. Message template fields are the same as those in "),e(o,{to:"/docs/api-notification/#field-message"},{default:i(()=>[s("notification api")]),_:1}),s(". Message template fields support dynamic token.")])]),_,n("p",null,[s("To support rule-based notification event filtering, "),S,s(" uses a "),n("a",w,[s("modified version"),e(a)]),s(" of "),n("a",B,[s("jmespath"),e(a)]),s(" to implement json query. The modified version allows defining custom functions that can be used in "),j,s(" field of subscription API and "),x,s(" field of subscription API. The functions must be implemented using JavaScript in config "),N,s(". The functions can even be "),C,s(". For example, the case-insensitive string matching function "),T,s(" shown in the example of that field can be created in file "),P]),R,n("p",null,[s("Consult jmespath.js source code on the "),n("a",F,[s("functionTable syntax"),e(a)]),s(" and "),n("a",z,[s("type constants"),e(a)]),s(" used by above code. Note the function can use any Node.js modules ("),n("em",null,[n("a",I,[s("lodash"),e(a)])]),s(" in this case).")]),A,n("p",null,[s("Guaranteed processing doesn't mean notification will be dispatched to every intended subscriber, however. Dispatch can still be rejected by smtp/sms server. Furthermore, even if dispatch is successful, it only means the sending is successful. It doesn't guarantee the recipient receives the notification. "),e(o,{to:"/docs/config/email.html#bounce"},{default:i(()=>[s("Bounce")]),_:1}),s(" may occur for a successful dispatch, for instance; or the recipient may not read the message.")]),n("p",null,[s("The guarantee comes at a performance penalty because result of each dispatch is written to database one by one, taking a toll on the database. It should be noted that the "),e(o,{to:"/docs/miscellaneous/benchmarks.html"},{default:i(()=>[s("benchmarks")]),_:1}),s(" were conducted without the guarantee.")]),D])}const E=c(u,[["render",L],["__file","index.html.vue"]]);export{E as default}; diff --git a/preview/assets/index.html-692e45cb.js b/preview/assets/index.html-692e45cb.js new file mode 100644 index 000000000..e3b593c29 --- /dev/null +++ b/preview/assets/index.html-692e45cb.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-8daa1a0e","path":"/","title":"","lang":"en-US","frontmatter":{"home":true,"heroImage":"/img/logo.svg","heroText":null,"actions":[{"text":"Quick Start →","link":"/docs/quickstart/","type":"primary"}],"features":[{"title":"Versatile","details":"
      \\n
    • Anonymous or authenticated subscriptions
    • \\n
    • Push and in-app pull notifications
    • \\n
    • Email and SMS push notification channels
    • \\n
    • Unicast and broadcast message types
    • \\n
    • Broadcast push notification filter rules specifiable by both sender and subscriber
    • \\n
    • Notification auto-gen from RSS
    • \\n
    \\n"},{"title":"Non-intrusive","details":"
      \\n
    • Handles common backend business logic only, allowing site developer implement frontend UI using widgets native to the site\\n
    • \\n
    • Loose coupling - interacts with user browser or other server components through RESTful API\\n
    • \\n
    \\n"},{"title":"Secure & Reliable","details":"
      \\n
    • Support end-to-end encryption\\n
    • \\n
    • Multiple authentication strategies including client certificate for server-server and OIDC for user-server
    • \\n
    • Resilient to node failures
    • \\n
    \\n"}],"footer":"The contents of this website are
    © 2016-present under the terms of the Apache License, Version 2.0.\\n","footerHtml":true,"head":[["title",{},"NotifyBC | A versatile notification API server"]]},"headers":[],"git":{},"filePathRelative":"index.md"}');export{e as data}; diff --git a/preview/assets/index.html-6b858d80.js b/preview/assets/index.html-6b858d80.js new file mode 100644 index 000000000..34152cfae --- /dev/null +++ b/preview/assets/index.html-6b858d80.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-23f62a3a","path":"/docs/config-email/","title":"Email","lang":"en-US","frontmatter":{"permalink":"/docs/config-email/"},"headers":[{"level":2,"title":"SMTP","slug":"smtp","link":"#smtp","children":[]},{"level":2,"title":"Throttle","slug":"throttle","link":"#throttle","children":[]},{"level":2,"title":"Inbound SMTP Server","slug":"inbound-smtp-server","link":"#inbound-smtp-server","children":[{"level":3,"title":"TCP Proxy Server","slug":"tcp-proxy-server","link":"#tcp-proxy-server","children":[]}]},{"level":2,"title":"Bounce","slug":"bounce","link":"#bounce","children":[]},{"level":2,"title":"List-unsubscribe by Email","slug":"list-unsubscribe-by-email","link":"#list-unsubscribe-by-email","children":[]}],"git":{},"filePathRelative":"docs/config/email.md"}');export{e as data}; diff --git a/preview/assets/index.html-6bb621c5.js b/preview/assets/index.html-6bb621c5.js new file mode 100644 index 000000000..2f651abf3 --- /dev/null +++ b/preview/assets/index.html-6bb621c5.js @@ -0,0 +1 @@ +const i=JSON.parse(`{"key":"v-391365f4","path":"/docs/config-overview/","title":"Configuration Overview","lang":"en-US","frontmatter":{"permalink":"/docs/config-overview/","prev":"/docs/what's-new/"},"headers":[{"level":2,"title":"Static Configurations","slug":"static-configurations","link":"#static-configurations","children":[]},{"level":2,"title":"Dynamic Configurations","slug":"dynamic-configurations","link":"#dynamic-configurations","children":[]}],"git":{},"filePathRelative":"docs/config/overview.md"}`);export{i as data}; diff --git a/preview/assets/index.html-6ca7c7e1.js b/preview/assets/index.html-6ca7c7e1.js new file mode 100644 index 000000000..84eecd9d1 --- /dev/null +++ b/preview/assets/index.html-6ca7c7e1.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-3cf0fa66","path":"/docs/conduct/","title":"Code of Conduct","lang":"en-US","frontmatter":{"permalink":"/docs/conduct/","editable":false,"prev":"/docs/upgrade/"},"headers":[],"git":{},"filePathRelative":"docs/meta/conduct.md"}');export{e as data}; diff --git a/preview/assets/index.html-6e427c03.js b/preview/assets/index.html-6e427c03.js new file mode 100644 index 000000000..e620cbdc1 --- /dev/null +++ b/preview/assets/index.html-6e427c03.js @@ -0,0 +1,7 @@ +import{_ as a,r as t,o as i,c as o,a as e,b as s,d as p,w as c,e as l}from"./app-73097456.js";const r={},d=e("h1",{id:"admin-ip-list",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#admin-ip-list","aria-hidden":"true"},"#"),s(" Admin IP List")],-1),u=e("em",null,"NotifyBC",-1),m=e("em",null,"localhost",-1),h=e("em",null,"adminIps",-1),v=e("em",null,"/src/config.ts",-1),k=l(`
    module.exports = {
    +  adminIps: ['127.0.0.1'],
    +};
    +

    to modify, create config object adminIps with updated list in file /src/config.local.js instead. For example, to add ip range 192.168.0.0/24 to the list

    module.exports = {
    +  adminIps: ['127.0.0.1', '192.168.0.0/24'],
    +};
    +

    It should be noted that NotifyBC may generate http requests sending to itself. These http requests are expected to be admin requests. If you have created an app cluster such as in Kubernetes, you should add the cluster ip range to adminIps. In Kubernetes, this ip range is a private ip range. For example, in BCGov's OpenShift cluster OCP4, the ip range starts with octet 10.

    `,4);function _(f,g){const n=t("RouterLink");return i(),o("div",null,[d,e("p",null,[s("By "),p(n,{to:"/docs/overview/#architecture"},{default:c(()=>[s("design")]),_:1}),s(", "),u,s(" classifies incoming requests into four types. For a request to be classified as super-admin, the request's source ip must be in admin ip list. By default, the list contains "),m,s(" only as defined by "),h,s(" in "),v]),k])}const x=a(r,[["render",_],["__file","index.html.vue"]]);export{x as default}; diff --git a/preview/assets/index.html-6e94f439.js b/preview/assets/index.html-6e94f439.js new file mode 100644 index 000000000..9ea70b0ce --- /dev/null +++ b/preview/assets/index.html-6e94f439.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-4cf2565c","path":"/docs/config-internalHttpHost/","title":"Internal HTTP Host","lang":"en-US","frontmatter":{"permalink":"/docs/config-internalHttpHost/"},"headers":[],"git":{},"filePathRelative":"docs/config/internalHttpHost.md"}');export{t as data}; diff --git a/preview/assets/index.html-70c61dd5.js b/preview/assets/index.html-70c61dd5.js new file mode 100644 index 000000000..b62385610 --- /dev/null +++ b/preview/assets/index.html-70c61dd5.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-04b96fc8","path":"/docs/api-overview/","title":"API Overview","lang":"en-US","frontmatter":{"permalink":"/docs/api-overview/","prev":"/docs/config-certificates/"},"headers":[],"git":{},"filePathRelative":"docs/api/overview.md"}');export{e as data}; diff --git a/preview/assets/index.html-76d114a5.js b/preview/assets/index.html-76d114a5.js new file mode 100644 index 000000000..0cad61091 --- /dev/null +++ b/preview/assets/index.html-76d114a5.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-a20dfce8","path":"/docs/web-console/","title":"Web Console","lang":"en-US","frontmatter":{"permalink":"/docs/web-console/"},"headers":[{"level":2,"title":"Ip whitelisting authentication","slug":"ip-whitelisting-authentication","link":"#ip-whitelisting-authentication","children":[]},{"level":2,"title":"Client certificate authentication","slug":"client-certificate-authentication","link":"#client-certificate-authentication","children":[]},{"level":2,"title":"Anonymous","slug":"anonymous","link":"#anonymous","children":[]},{"level":2,"title":"Access token authentication","slug":"access-token-authentication","link":"#access-token-authentication","children":[]},{"level":2,"title":"OIDC authentication","slug":"oidc-authentication","link":"#oidc-authentication","children":[]},{"level":2,"title":"SiteMinder authentication","slug":"siteminder-authentication","link":"#siteminder-authentication","children":[]}],"git":{},"filePathRelative":"docs/getting-started/web-console.md"}');export{t as data}; diff --git a/preview/assets/index.html-797fb107.js b/preview/assets/index.html-797fb107.js new file mode 100644 index 000000000..aa3cf6ded --- /dev/null +++ b/preview/assets/index.html-797fb107.js @@ -0,0 +1 @@ +import{_ as c,r,o as d,c as u,a as e,b as i,d as t,w as a,e as s}from"./app-73097456.js";const h={},p=s('

    Overview

    NotifyBC is a general purpose API Server to manage subscriptions and dispatch notifications. It aims to implement some common backend processes of a notification service without imposing any constraints on the UI frontend, nor impeding other server components' functionality. This is achieved by interacting with user browser and other server components through RESTful API and other standard protocols in a loosely coupled way.

    Features

    NotifyBC facilitates both anonymous and authentication-enabled secure webapps implementing notification feature. A NotifyBC server instance supports multiple notification services. A service is a topic of interest that user wants to receive updates. It is used as the partition of notification messages and user subscriptions. A user may subscribe to a service in multiple push delivery channels allowed. A user may subscribe to multiple services. In-app pull notification doesn't require subscription as it's not intrusive to user.

    notification

    • both in-app pull notifications (a.k.a. messages or alerts) and push notifications
    • multiple push notifications delivery channels
      • email
      • sms
    • unicast and broadcast message types
    • future-dated notifications
    • for in-app pull notifications
      • support read and deleted message states
      • message expiration
      • deleted messages are not deleted immediately for auditing and recovery purposes
    • for broadcast push notifications
      • allow both sync and async POST API calls. For async API call, an optional callback url is supported
      • can be auto-generated from RSS feeds
      • allow user to specify filter rules evaluated against data contained in the notification
      • allow sender to specify filter rules evaluated against data contained in the subscription
      • allow application developer to create custom filter functions used by the filter rules mentioned above

    subscription and un-subscription

    • verify the ownership of push notification subscription channel:
      • generates confirmation code based on a regex input
      • send confirmation request to unconfirmed subscription channel
      • verify confirmation code
    • generate random un-subscription code
    • send acknowledgement message after un-subscription for anonymous subscribers
    • bulk unsubscription
    • list-unsubscribe by email
    • track bounces and unsubscribe the recipient from all subscriptions when hard bounce count exceeds threshold
    • sms user can unsubscribe by replying a shortcode keyword with Swift sms provider

    mail merge

    Strings in notification or subscription message that are enclosed between curly braces { } are called tokens, also known as placeholders. Tokens are replaced based on the context of notification or subscription when dispatching the message. To avoid treating a string between curly braces as a token, escape the curly braces with backslash \\. For example \\{i_am_not_a_token\\} is not a token. It will be rendered as {i_am_not_a_token}.

    Tokens whose names are predetermined by NotifyBC are called static tokens; otherwise they are called dynamic tokens.

    static tokens

    NotifyBC recognizes following case-insensitive static tokens. Most of the names are self-explanatory.

    • {subscription_confirmation_url}
    • {subscription_confirmation_code}
    • {service_name}
    • {http_host} - http host in the form http(s): //<host_name>:<port>. The value is obtained from the http request that triggers the message
    • {rest_api_root} - REST API URL path prefix
    • {subscription_id}
    • anonymous unsubscription related tokens
      • {unsubscription_url}
      • {unsubscription_all_url} - url to unsubscribe all services the user has subscribed on this NotifyBC instance
      • {unsubscription_code}
      • {unsubscription_reversion_url}
      • {unsubscription_service_names} - includes {service_name} and additional services user has unsubscribed, prefixed with conditionally pluralized word service.

    dynamic tokens

    Dynamic tokens are replaced with correspondingly named sub-field of data field in the notification or subscription if exist. Qualify token name with notification:: or subscription:: to indicate the source of substitution. If token name is not qualified, then both notification and subscription are checked, with notification taking precedence. Nested and indexed sub-fields are supported.

    Examples

    • {notification::description} is replaced with field data.description of the notification if exist
    • {subscription::gender} is replaced with field data.gender of the subscription if exist
    • {addresses[0].city} is replaced with field data.addresses[0].city of the notification if exist; otherwise is replaced with field data.addresses[0].city of the subscription if exist
    • {nonexistingDataField} is unreplaced if neither notification nor subscription contains data.nonexistingDataField
    ',18),m=e("em",null,"{subscription::...}",-1),f=s('

    Notification by RSS feeds relies on dynamic token

    A notification created by RSS feeds relies on dynamic token to supply the context to message template. In this case the data field contains the RSS item.

    Architecture

    Request Types

    NotifyBC, designed to be a microservice, doesn't use full-blown ACL to secure API calls. Instead, it classifies incoming requests into admin and user types. The key difference is while both admin and user can subscribe to notifications, only admin can post notifications.

    Each type has two subtypes based on following criteria

    ',5),b=e("p",null,"super-admin, if the request meets both of the following two requirements",-1),g=e("p",null,"The request carries one of the following two attributes",-1),y=e("li",null,"the source ip is in the admin ip list",-1),w=e("em",null,"NotifyBC",-1),v=e("li",null,[e("p",null,"The request doesn't contain any of following case insensitive HTTP headers, with the first three being SiteMinder headers"),e("ul",null,[e("li",null,"sm_universalid"),e("li",null,"sm_user"),e("li",null,"smgov_userdisplayname"),e("li",null,"is_anonymous")])],-1),_=s('
  • admin, if the request is not super-admin and meets one of the following criteria

    • has a valid access token associated with an builtin admin user created and logged in using the administrator api, and the request doesn't contain any HTTP headers listed above
    • has a valid OIDC access token containing customizable admin profile attributes

    access token disambiguation

    Here the term access token has been used to refer two different things

    1. the token associated with a builtin admin user
    2. the token generated by OIDC provider.

    To reduce confusion, throughout the documentation the former is called access token and the latter is called OIDC access token.

  • authenticated user, if the request is neither super-admin nor admin, and meets one fo the following criteria

    • contains any of the 3 SiteMinder headers listed above, and comes from either trusted SiteMinder proxy or admin ip list
    • contains a valid OIDC access token
  • anonymous user, if the request doesn't meet any of the above criteria

  • ',3),k=s("

    The only extra privileges that a super-admin has over admin are that super-admin can perform CRUD operations on configuration, bounce and administrator entities through REST API. In the remaining docs, when no further distinction is necessary, an admin request refers to both super-admin and admin request; a user request refers to both authenticated and anonymous request.

    An admin request carries full authorization whereas user request has limited access. For example, a user request is not allowed to

    • send notification
    • bypass the delivery channel confirmation process when subscribing to a service
    • retrieve push notifications through API (can only receive notification from push notification channel such as email)
    • retrieve in-app notifications that is not targeted to the current user

    The result of an API call to the same end point may differ depending on the request type. For example, the call GET /notifications without a filter will return all notifications to all users for an admin request, but only non-deleted, non-expired in-app notifications for authenticated user request, and forbidden for anonymous user request. Sometimes it is desirable for a request from admin ip list, which would normally be admin request, to be voluntarily downgraded to user request in order to take advantage of predefined filters such as the ones described above. This can be achieved by adding one of the HTTP headers listed above to the request. This is also why admin request is not determined by ip or token alone.

    ",4),x=e("em",null,"NotifyBC",-1),q=["src"],C=s('

    Authentication Strategies

    API requests to NotifyBC can be either anonymous or authenticated. As alluded in Request Types above, NotifyBC supports following authentication strategies

    1. ip whitelisting
    2. client certificate
    3. access token associated with an builtin admin user
    4. OpenID Connect (OIDC)
    5. CA SiteMinder

    Authentication is performed in above order. Once a request passed an authentication strategy, the rest strategies are skipped. A request that failed all authentication strategies is anonymous.

    The mapping between authentication strategy and request type is

    AdminUser
    Super-adminadminauthenticatedanonymous
    ip whitelisting
    client certifcate
    access token
    OIDC
    SiteMinder

    Which authentication strategy to use?

    Because ip whitelist doesn't expire and client certificate usually has a relatively long expiration period (say one year), they are suitable for long-running unattended server processes such as server-side code of web apps, cron jobs, IOT sensors etc. The server processes have to be trusted because once authenticated, they have full privilege to NotifyBC. Usually the server processes and NotifyBC instance are in the same administrative domain, i.e. managed by the same admin group of an organization.

    By contrast, OIDC and SiteMinder use short-lived tokens or session cookies. Therefore they are only suitable for interactive user sessions.

    Access token associated with an builtin admin user should be avoided whenever possible.

    Here are some common scenarios and recommendations

    • For server-side code of web apps

      • use OIDC if the web app is OIDC enabled and user requests can be proxied to NotifyBC by web app; otherwise
      • use ip whitelisting if obtaining ip is feasible; otherwise
      • use client certificate (requires a little more config than ip whitelisting)
    • For front-end browser-based web apps such as SPAs

      • use OIDC
    • For server apps that send requests spontaneously such as IOT sensors, cron jobs

      • use ip whitelisting if obtaining ip is feasible; otherwise
      • client certificate
    • If NotifyBC is ued by a SiteMinder protected web apps and NotifyBC is also protected by SiteMinder

      • use SiteMinder

    Application Framework

    ',8),I=e("em",null,"NotifyBC",-1),S={href:"https://nestjs.com/",target:"_blank",rel:"noopener noreferrer"},T=e("em",null,"NotifyBC",-1),A={href:"https://docs.nestjs.com/",target:"_blank",rel:"noopener noreferrer"};function N(l,B){const n=r("RouterLink"),o=r("ExternalLinkIcon");return d(),u("div",null,[p,e("p",null,[i("As exception, in order to prevent spamming by unconfirmed subscribers, dynamic tokens in subscription "),t(n,{to:"/docs/config-subscription/#confirmation-request-message"},{default:a(()=>[i("confirmation request message")]),_:1}),i(" and "),t(n,{to:"/docs/config-subscription/#duplicated-subscription"},{default:a(()=>[i("duplicated subscription")]),_:1}),i(" message are not replaced with subscription data, for example "),m,i(" tokens are left unchanged.")]),f,e("ul",null,[e("li",null,[b,e("ol",null,[e("li",null,[g,e("ul",null,[y,e("li",null,[i("has a client certificate that is signed using "),w,i(" server certificate. See "),t(n,{to:"/docs/config/certificates.html#client-certificate-authentication"},{default:a(()=>[i("Client certificate authentication")]),_:1}),i(" on how to sign.")])])]),v])]),_]),k,e("p",null,[i("The way "),x,i(" interacts with other components is diagrammed below. "),e("img",{src:l.$withBase("/img/architecture.svg"),alt:"architecture diagram"},null,8,q)]),C,e("p",null,[I,i(" is created on "),e("a",S,[i("NestJS"),t(o)]),i(". Contributors to source code of "),T,i(" should be familiar with NestJS. "),e("a",A,[i("NestJS Docs"),t(o)]),i(" serves a good complement to this documentation.")])])}const O=c(h,[["render",N],["__file","index.html.vue"]]);export{O as default}; diff --git a/preview/assets/index.html-7af3bcd3.js b/preview/assets/index.html-7af3bcd3.js new file mode 100644 index 000000000..71c946703 --- /dev/null +++ b/preview/assets/index.html-7af3bcd3.js @@ -0,0 +1,95 @@ +import{_ as r,r as l,o as p,c,a as e,b as a,d as n,w as t,e as o}from"./app-73097456.js";const d={},u=o('

    Upgrade Guide

    Major version can only be upgraded incrementally from immediate previous major version, i.e. from N to N+1.

    v1 to v2

    Upgrading NotifyBC from v1 to v2 involves two steps

    1. Update your client code if needed
    2. Upgrade NotifyBC server

    Update your client code

    NotifyBC v2 introduced backward incompatible API changes documented in the rest of this section. If your client code will be impacted by the changes, update your code to address the incompatibility first.

    Query parameter array syntax

    In v1 array can be specified in query parameter using two formats

    1. by enclosing array elements in square brackets such as &additionalServices=["s1","s2] in one query parameter
    2. by repeating the query parameters, for example &additionalServices=s1&additionalServices=s2

    In v2 only the latter format is supported.

    Date-Time fields

    In v1 date-time fields can be specified in date-only string such as 2020-01-01. In v2 the field must be specified in ISO 8601 extended format such as 2020-01-01T00:00:00Z.

    Return status codes

    HTTP response code of success calls to following APIs are changed from 200 to 204

    ',15),m=e("h4",{id:"administrator-api",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#administrator-api","aria-hidden":"true"},"#"),a(" Administrator API")],-1),v=e("em",null,"Administrator",-1),h=e("em",null,"UserCredential",-1),b=o(`

    Upgrade NotifyBC server

    The procedure to upgrade from v1 to v2 depends on how v1 was installed.

    Source-code Installation

    1. Stop NotifyBC
    2. Backup app root and database!
    3. Make sure current branch is tracking correct remote branch
      git remote set-url origin https://github.com/bcgov/NotifyBC.git
      +git branch -u origin/main
      +
    4. Make a note of any extra packages added to package.json
    5. Run git pull && git checkout tags/v2.x.x from app root, replace v2.x.x with a v2 release, preferably latest, found in GitHub such as v2.9.0.
    6. Make sure version property in package.json is 2.x.x
    7. Add back extra packages noted in step 4
    8. Move server/config.(local|dev|production).(js|json) to src/ if exists
    9. Move server/datasources.(local|dev|production).(js|json) to src/datasources/db.datasource.(local|dev|production).(js|json) if exists. Notice the file name has changed.
    10. Move server/middleware.*.(js|json) to src/ if exists. Reorganize top level properties to all or apiOnly, where all applies to all requests including web console and apiOnly applies to API requests only. For example, given
    module.exports = {
    +  initial: {
    +    compression: {},
    +  },
    +  'routes:before': {
    +    morgan: {
    +      enabled: false,
    +    },
    +  },
    +};
    +

    if compression middleware will be applied to all requests and morgan will be applied to API requests only, then change the file to

    module.exports = {
    +  all: {
    +    compression: {},
    +  },
    +  apiOnly: {
    +    morgan: {
    +      enabled: false,
    +    },
    +  },
    +};
    +
    1. Run
    npm i && npm run build
    +
    1. Start server by running npm run start or Windows Service

    OpenShift Installation

    `,11),g=o(`
  • Run

    git clone https://github.com/bcgov/NotifyBC.git
    +cd NotifyBC
    +
  • Run

    oc delete bc/notify-bc
    +oc process -f .openshift-templates/notify-bc-build.yml | oc create -f-
    +

    ignore AlreadyExists errors

  • `,2),f=e("p",null,"For each environment,",-1),k=o(`
  • run

    oc project <yourprojectname-<env>>
    +oc delete dc/notify-bc-app dc/notify-bc-cron
    +oc process -f .openshift-templates/notify-bc.yml | oc create -f-
    +

    ignore AlreadyExists errors

  • copy value of environment variable MONGODB_USER from mongodb deployment config to the same environment variable of deployment config notify-bc-app and notify-bc-cron, replacing existing value

  • remove middleware.local.json from configMap notify-bc

  • add middleware.local.js to configMap notify-bc with following content

    module.exports = {
    +  apiOnly: {
    +    morgan: {
    +      enabled: false,
    +    },
    +  },
    +};
    +
  • `,4),y=o('

    OpenShift template to Helm

    Upgrading NotifyBC on OpenShift created from OpenShift template to Helm involves 2 steps

    1. Customize and Install Helm chart
    2. Migrate MongoDB data

    Customize and install Helm chart

    ',4),_=e("em",null,"helm/values.local.yaml",-1),x=o(`
    • notify-bc configMap
    • web route host name and certificates

    Then run helm install with documented arguments to install a release.

    Migrate MongoDB data

    1. backup data from source

      oc exec -i <mongodb-pod> -- bash -c 'mongodump -u "$MONGODB_USER" \\
      +-p "$MONGODB_PASSWORD" -d $MONGODB_DATABASE --gzip --archive' > notify-bc.gz
      +

      replace <mongodb-pod> with the mongodb pod name.

    2. restore backup to target

      cat notify-bc.gz | oc exec -i <mongodb-pod-0> -- \\
      +bash -c 'mongorestore -u "$MONGODB_USERNAME" -p"$MONGODB_PASSWORD" \\
      +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_DATABASE --gzip --drop --archive'
      +

      replace <mongodb-pod-0> with the first pod name in the mongodb stateful set.

    If both source and target are in the same OpenShift cluster, the two operations can be combined into one

    oc exec -i <mongodb-pod> -- bash -c 'mongodump -u "$MONGODB_USER" \\
    +-p "$MONGODB_PASSWORD" -d $MONGODB_DATABASE --gzip --archive' | \\
    +oc exec -i <mongodb-pod-0> -- bash -c \\
    +'mongorestore -u "$MONGODB_USERNAME" -p"$MONGODB_PASSWORD" \\
    +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_DATABASE --gzip --drop --archive'
    +

    v2 to v3

    v3 introduced following backward incompatible changes

    1. Changed output-only fields failedDispatches and successDispatches to dispatch.failed and dispatch.successful respectively in Notification api. If your client app depends on the fields, change accordingly.
    2. Changed config notification.logSuccessfulBroadcastDispatches to notification.guaranteedBroadcastPushDispatchProcessing and reversed default value from false to true. If you don't want NotifyBC guarantees processing all subscriptions to a broadcast push notification in a node failure resilient way, perhaps for performance reason, set the value to false in file /src/config.local.js.

    After above changes are addressed, upgrading to v3 is as simple as

    git pull
    +git checkout tags/v3.x.x
    +npm i && npm run build
    +

    or, if NotifyBC is deployed to Kubernetes using Helm.

    git pull
    +git checkout tags/v3.x.x
    +helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
    +

    Replace v3.x.x with a v3 release, preferably latest, found in GitHub such as v3.1.2.

    v3 to v4

    v4 introduced following backward incompatible changes that need to be addressed in this order

    `,16),q=o("
  • The precedence of config, middleware and datasource files has been changed. Local file takes higher precedence than environment specific file. For example, for config file, the new precedence in ascending order is

    1. default file /src/config.ts
    2. environment specific file /src/config.<env>.js, where <env> is determined by environment variable NODE_ENV
    3. local file /src/config.local.js

    To upgrade, if you have environment specific file, merge its content into the local file, then delete it.

  • ",1),w=e("em",null,"smtp",-1),S=e("em",null,"email.smtp",-1),B=e("em",null,"inboundSmtpServer",-1),M=e("em",null,"email.inboundSmtpServer",-1),N=e("em",null,"email.inboundSmtpServer.bounce",-1),D=e("em",null,"email.bounce",-1),O=e("li",null,[e("p",null,[a("Config "),e("em",null,"notification.handleBounce"),a(" is changed to "),e("em",null,"email.bounce.enabled"),a(".")])],-1),j=e("em",null,"notification.handleListUnsubscribeByEmail",-1),A=e("em",null,"email.listUnsubscribeByEmail.enabled",-1),C=e("em",null,"smsServiceProvider",-1),I=e("em",null,"sms.provider",-1),E=e("em",null,"sms",-1),P=e("em",null,"sms.providerSettings",-1),$=e("em",null,"sms",-1),R=e("em",null,"provider",-1),T=e("em",null,"providerSettings",-1),U=e("em",null,"throttle",-1),G=e("li",null,[e("p",null,[a("Legacy config "),e("em",null,"subscription.unsubscriptionEmailDomain"),a(" is removed. If you have it defined in your file "),e("em",null,"/src/config.local.js"),a(", replace with "),e("em",null,"email.inboundSmtpServer.domain"),a(".")])],-1),H=e("li",null,[e("p",null,[a("Helm chart added Redis that requires authentication by default. Create a new password in "),e("em",null,"helm/values.local.yaml"),a(" to facilitate upgrading")])],-1),z=o(`
    # in file helm/values.local.yaml
    +redis:
    +  auth:
    +    password: '<secret>'
    +

    After above changes are addressed, upgrading to v4 is as simple as

    git pull
    +git checkout tags/v4.x.x
    +npm i && npm run build
    +

    or, if NotifyBC is deployed to Kubernetes using Helm.

    git pull
    +git checkout tags/v4.x.x
    +helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
    +

    Replace v4.x.x with a v4 release, preferably latest, found in GitHub such as v4.0.0.

    v4 to v5

    v5 introduced following backward incompatible changes

    `,8),L=e("li",null,[e("p",null,"Replica set is required for MongoDB. If you deployed NotifyBC using Helm, replica set is already enabled by default.")],-1),F=e("li",null,[e("p",null,[a("If you use default in-memory database, data in "),e("em",null,"server/database/data.json"),a(" will not be migrated automatically. Manually migrate if necessary.")])],-1),V=e("p",null,[a("Update file "),e("em",null,"src/datasources/db.datasource.local.[json|js|ts]")],-1),K=e("li",null,[a("rename "),e("em",null,"url"),a(" property to "),e("em",null,"uri")],-1),W={href:"https://loopback.io/doc/en/lb4/MongoDB-connector.html#creating-a-mongodb-data-source",target:"_blank",rel:"noopener noreferrer"},Q={href:"https://mongoosejs.com/docs/connections.html#options",target:"_blank",rel:"noopener noreferrer"},Z=e("em",null,"host",-1),J=e("em",null,"port",-1),X=e("em",null,"database",-1),Y=e("em",null,"uri",-1),ee=o(`

    For example, change

    {
    +  "name": "db",
    +  "connector": "mongodb",
    +  "url": "mongodb://127.0.0.1:27017/notifyBC"
    +}
    +

    to

    {
    +  "uri": "mongodb://127.0.0.1:27017/notifyBC"
    +}
    +

    If you deployed NotifyBC using Helm, this is taken care of.

    `,5),ae={href:"https://loopback.io/doc/en/lb4/Where-filter.html#operators",target:"_blank",rel:"noopener noreferrer"},ne={href:"https://www.mongodb.com/docs/manual/reference/operator/query/",target:"_blank",rel:"noopener noreferrer"},se=o("
    Loopback operatorsMongoDB operators
    eq$eq
    and$and
    or$or
    gt, gte$gt, $gte
    lt, lte$lt, $lte
    between(no equivalent, replace with $gt, $and and $lt)
    inq, nin$in, $nin
    near$near
    neq$ne
    like, nlike(replace with $regexp)
    like, nlike, options: i(replace with $regexp)
    regexp$regex
    ",1),te=e("em",null,"order",-1),oe={href:"https://loopback.io/doc/en/lb4/Order-filter.html",target:"_blank",rel:"noopener noreferrer"},ie={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},le=o(`
    GET http://localhost:3000/api/configurations?filter={"order":["serviceName asc"]}
    +

    change to either

    GET http://localhost:3000/api/configurations?filter={"order":[["serviceName","asc"]]}
    +

    or

    GET http://localhost:3000/api/configurations?filter={"order":"serviceName"}
    +
    `,5),re=e("li",null,[e("p",null,"In MongoDB administrator collection, email has changed from case-sensitively unique to case-insensitively unique. Make sure administrator emails differ not just by case.")],-1),pe=e("li",null,[e("p",null,[a("When a subscription is created by anonymous user, the "),e("em",null,"data"),a(" field is preserved. In earlier versions this field is deleted.")])],-1),ce=e("li",null,[e("p",null,"Dynamic tokens in subscription confirmation request message and duplicated subscription message are not replaced with subscription data, for example {subscription::...} tokens are left unchanged. Update the template of the two messages if dynamic tokens in them depends on subscription data.")],-1),de=o(`
  • If you deployed NotifyBC using Helm, change MongoDB password format in your local values yaml file from

    # in file helm/values.local.yaml
    +mongodb:
    +  auth:
    +    rootPassword: <secret>
    +    replicaSetKey: <secret>
    +    password: <secret>
    +

    to

    # in file helm/values.local.yaml
    +mongodb:
    +  auth:
    +    rootPassword: <secret>
    +    replicaSetKey: <secret>
    +    passwords:
    +      - <secret>
    +
  • `,1),ue=o(`

    After above changes are addressed, to upgrade NotifyBC to v5,

    • if NotifyBC is deployed from source code, run

      git pull
      +git checkout tags/v5.x.x
      +npm i && npm run build
      +
    • if NotifyBC is deployed to Kubernetes using Helm,

      1. backup MongoDB database
      2. run
        helm uninstall <release-name>
        +
        Replace <release-name> with installed helm release name
      3. delete PVCs used by MongoDB stateful set
      4. run
        git pull
        +git checkout tags/v5.x.x
        +helm install <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
        +
        Replace
        • v5.x.x with a v5 release, preferably latest, found in GitHub such as v5.0.0.
        • <release-name> with installed helm release name
        • <platform> with openshift or aks depending on your platform
      5. restore MongoDB database
    `,2);function me(ve,he){const s=l("RouterLink"),i=l("ExternalLinkIcon");return p(),c("div",null,[u,e("ul",null,[e("li",null,[a("most PATCH by id requests except for "),n(s,{to:"/docs/api-subscription/#update-a-subscription"},{default:t(()=>[a("Update a Subscription ")]),_:1})]),e("li",null,[a("most PUT by id requests except for "),n(s,{to:"/docs/api-subscription/#replace-a-subscription"},{default:t(()=>[a("Replace a Subscription")]),_:1})]),e("li",null,[a("most DELETE by id requests except for "),n(s,{to:"/docs/api-subscription/#delete-a-subscription-unsubscribing"},{default:t(()=>[a("Delete a Subscription (unsubscribing)")]),_:1})])]),m,e("ul",null,[e("li",null,[a("Password is saved to "),v,a(" in v1 and "),h,a(" in v2. Password is not migrated. New password has to be created by following "),n(s,{to:"/docs/api-administrator/#create-update-an-administrator-s-usercredential"},{default:t(()=>[a("Create/Update an Administrator's UserCredential ")]),_:1}),a(".")]),e("li",null,[n(s,{to:"/docs/api/administrator.html#sign-up"},{default:t(()=>[a("Complexity rules")]),_:1}),a(" have been applied to passwords.")]),e("li",null,[n(s,{to:"/docs/api-administrator/#login"},{default:t(()=>[a("login")]),_:1}),a(" API is open to non-admin")])]),b,e("ol",null,[g,e("li",null,[e("p",null,[a("Follow OpenShift "),n(s,{to:"/docs/installation/#build"},{default:t(()=>[a("Build")]),_:1})])]),e("li",null,[f,e("ol",null,[k,e("li",null,[e("p",null,[a("Follow OpenShift "),n(s,{to:"/docs/installation/#deploy"},{default:t(()=>[a("Deploy")]),_:1}),a(" or "),n(s,{to:"/docs/installation/#change-propagation"},{default:t(()=>[a("Change Propagation")]),_:1}),a(" to tag image")])])])])]),y,e("p",null,[a("Follow "),n(s,{to:"/docs/installation/#customizations"},{default:t(()=>[a("customizations")]),_:1}),a(" to create file "),_,a(" containing customized configs such as")]),x,e("ol",null,[q,e("li",null,[e("p",null,[a("Config "),w,a(" is changed to "),S,a(". See "),n(s,{to:"/docs/config/email.html#smtp"},{default:t(()=>[a("SMTP")]),_:1}),a(" for example.")])]),e("li",null,[e("p",null,[a("Config "),B,a(" is changed to "),M,a(". See "),n(s,{to:"/docs/config/email.html#inbound-smtp-server"},{default:t(()=>[a("Inbound SMTP Server")]),_:1}),a(" for example.")])]),e("li",null,[e("p",null,[a("Config "),N,a(" is changed to "),D,a(". See "),n(s,{to:"/docs/config/email.html#bounce"},{default:t(()=>[a("Bounce")]),_:1}),a(" for example.")])]),O,e("li",null,[e("p",null,[a("Config "),j,a(" is changed to "),A,a(". See "),n(s,{to:"/docs/config/email.html#list-unsubscribe-by-email"},{default:t(()=>[a("List-unsubscribe by Email")]),_:1}),a(" for example.")])]),e("li",null,[e("p",null,[a("Config "),C,a(" is changed to "),I,a(". See "),n(s,{to:"/docs/config/sms.html#provider"},{default:t(()=>[a("Provider")]),_:1}),a(" for example.")])]),e("li",null,[e("p",null,[a("SMS service provider specific settings defined in config "),E,a(" are changed to "),P,a(". See "),n(s,{to:"/docs/config/sms.html#provider-settings"},{default:t(()=>[a("Provider Settings")]),_:1}),a(" for example. The config object "),$,a(" now encapsulates all SMS configs - "),R,a(", "),T,a(" and "),U,a(".")])]),G,H]),z,e("ol",null,[L,F,e("li",null,[V,e("ol",null,[K,e("li",null,[a("for other properties, instead of following "),e("a",W,[a("LoopBack MongoDB data source"),n(i)]),a(", follow "),e("a",Q,[a("Mongoose connection options"),n(i)]),a(". In particular, "),Z,a(", "),J,a(" and "),X,a(" properties are no longer supported. Use "),Y,a(" instead.")])]),ee]),e("li",null,[e("p",null,[a("API querying operators have changed. Replace following "),e("a",ae,[a("Loopback operators"),n(i)]),a(" with corresponding "),e("a",ne,[a("MongoDB operators"),n(i)]),a(" at your client-side API call.")]),se]),e("li",null,[e("p",null,[a("API "),te,a(" filter syntax has changed. Replace syntax from "),e("a",oe,[a("Loopback"),n(i)]),a(" to "),e("a",ie,[a("Mongoose"),n(i)]),a(" at client-side API call. For example, if your client-side code generates following API call")]),le]),re,pe,ce,e("li",null,[e("p",null,[n(s,{to:"/docs/config-email/#inbound-smtp-server"},{default:t(()=>[a("Inbound SMTP Server")]),_:1}),a(" no longer accepts command line arguments or environment variables as inputs. All inputs have to be defined in config files shown in the link.")])]),de]),ue])}const ge=r(d,[["render",me],["__file","index.html.vue"]]);export{ge as default}; diff --git a/preview/assets/index.html-7b20a00b.js b/preview/assets/index.html-7b20a00b.js new file mode 100644 index 000000000..c2cdbba19 --- /dev/null +++ b/preview/assets/index.html-7b20a00b.js @@ -0,0 +1 @@ +import{_ as a,r as i,o as l,c as h,a as e,b as t,d as o,w as r,e as d}from"./app-73097456.js";const c={},u=e("h1",{id:"what-s-new",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#what-s-new","aria-hidden":"true"},"#"),t(" What's New")],-1),_=e("em",null,"NotifyBC",-1),p={href:"https://semver.org/",target:"_blank",rel:"noopener noreferrer"},f=e("h2",{id:"v5",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v5","aria-hidden":"true"},"#"),t(" v5")],-1),m=e("h3",{id:"v5-1-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v5-1-0","aria-hidden":"true"},"#"),t(" v5.1.0")],-1),b={href:"https://github.com/bcgov/NotifyBC/issues/85",target:"_blank",rel:"noopener noreferrer"},v=e("li",null,"Changed package manager from yarn to npm",-1),g=e("h3",{id:"v5-0-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v5-0-0","aria-hidden":"true"},"#"),t(" v5.0.0")],-1),k=e("ul",null,[e("li",null,[t("Runs on "),e("em",null,"NestJS")]),e("li",null,"Bitnami MongoDB Helm chart is updated from version 10.7.1 to 14.3.2, with corresponding MongoDB from 4.4 to 7.0.4"),e("li",null,"Bitnami Redis Helm chart is updated from version 14.7.2 to 16.13.2, with corresponding Redis from 6.2.4 to 6.2.7")],-1),y={class:"custom-container tip"},B=e("p",{class:"custom-container-title"},"Why v5?",-1),N=e("em",null,"NotifyBC",-1),w={href:"https://loopback.io/",target:"_blank",rel:"noopener noreferrer"},x=e("em",null,"Loopback",-1),C=e("em",null,"Loopback",-1),S=d("
    1. features such as GraphQL have been in experimental state for years
    2. recent commits are mostly chores rather than enhancements
    3. core developers have ceased to contribute

    To pave the way for future growth, switching platform becomes necessary. NestJS was chosen because

    1. both NestJS and Loopback are server-side Node.js frameworks
    2. NestJS has the closest feature set as Loopback. To a large extent NestJS is a superset of Loopback
    3. NestJS incorporates more technologies
    ",3),L=e("h2",{id:"v4",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v4","aria-hidden":"true"},"#"),t(" v4")],-1),I=e("h3",{id:"v4-1-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v4-1-0","aria-hidden":"true"},"#"),t(" v4.1.0")],-1),R={href:"https://github.com/bcgov/NotifyBC/issues/50",target:"_blank",rel:"noopener noreferrer"},J=e("li",null,"applied sms throttle to all sms messages rather than just broadcast push notification.",-1),j=e("li",null,"docs updates",-1),A=e("h3",{id:"v4-0-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v4-0-0","aria-hidden":"true"},"#"),t(" v4.0.0")],-1),V={href:"https://github.com/bcgov/NotifyBC/issues/48",target:"_blank",rel:"noopener noreferrer"},D=e("li",null,"Re-ordered config file precedence",-1),E=e("li",null,"Re-organized Email and SMS configs",-1),H=e("li",null,"docs updates",-1),M=e("h2",{id:"v3",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v3","aria-hidden":"true"},"#"),t(" v3")],-1),T=e("h3",{id:"v3-1-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v3-1-0","aria-hidden":"true"},"#"),t(" v3.1.0")],-1),G={href:"https://github.com/bcgov/NotifyBC/issues/45",target:"_blank",rel:"noopener noreferrer"},U=e("li",null,"docs updates",-1),W=e("h3",{id:"v3-0-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v3-0-0","aria-hidden":"true"},"#"),t(" v3.0.0")],-1),O={href:"https://github.com/bcgov/NotifyBC/issues/36",target:"_blank",rel:"noopener noreferrer"},z={href:"https://github.com/bcgov/NotifyBC/issues/37",target:"_blank",rel:"noopener noreferrer"},P={href:"https://github.com/bcgov/NotifyBC/issues/38",target:"_blank",rel:"noopener noreferrer"},Q={href:"https://github.com/bcgov/NotifyBC/issues/39",target:"_blank",rel:"noopener noreferrer"},q={href:"https://github.com/bcgov/NotifyBC/issues/40",target:"_blank",rel:"noopener noreferrer"},F={href:"https://github.com/bcgov/NotifyBC/issues/41",target:"_blank",rel:"noopener noreferrer"},K={href:"https://github.com/bcgov/NotifyBC/issues/42",target:"_blank",rel:"noopener noreferrer"},X=e("li",null,"docs updates",-1),Y=e("h2",{id:"v2",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2","aria-hidden":"true"},"#"),t(" v2")],-1),Z=e("h3",{id:"v2-9-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-9-0","aria-hidden":"true"},"#"),t(" v2.9.0")],-1),$={href:"https://github.com/bcgov/NotifyBC/issues/34",target:"_blank",rel:"noopener noreferrer"},ee=e("li",null,"docs updates",-1),te=e("h3",{id:"v2-8-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-8-0","aria-hidden":"true"},"#"),t(" v2.8.0")],-1),oe={href:"https://github.com/bcgov/NotifyBC/issues/28",target:"_blank",rel:"noopener noreferrer"},se={href:"https://github.com/bcgov/NotifyBC/issues/32",target:"_blank",rel:"noopener noreferrer"},ne=e("li",null,"docs updates",-1),re=e("h3",{id:"v2-7-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-7-0","aria-hidden":"true"},"#"),t(" v2.7.0")],-1),ie={href:"https://github.com/bcgov/NotifyBC/issues/26",target:"_blank",rel:"noopener noreferrer"},ae=e("li",null,"docs updates",-1),le=e("h3",{id:"v2-6-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-6-0","aria-hidden":"true"},"#"),t(" v2.6.0")],-1),he=e("ul",null,[e("li",null,"Helm chart updates"),e("li",null,"docs updates")],-1),de=e("h3",{id:"v2-5-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-5-0","aria-hidden":"true"},"#"),t(" v2.5.0")],-1),ce={href:"https://github.com/bcgov/NotifyBC/tree/main/helm",target:"_blank",rel:"noopener noreferrer"},ue=e("li",null,"docs updates",-1),_e=e("h3",{id:"v2-4-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-4-0","aria-hidden":"true"},"#"),t(" v2.4.0")],-1),pe={href:"https://github.com/bcgov/NotifyBC/issues/16",target:"_blank",rel:"noopener noreferrer"},fe=e("li",null,"misc web console adjustments",-1),me=e("li",null,"docs updates",-1),be=e("h3",{id:"v2-3-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-3-0","aria-hidden":"true"},"#"),t(" v2.3.0")],-1),ve={href:"https://github.com/bcgov/NotifyBC/issues/15",target:"_blank",rel:"noopener noreferrer"},ge=e("li",null,"misc web console adjustments",-1),ke=e("li",null,"docs updates",-1),ye=e("h3",{id:"v2-2-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-2-0","aria-hidden":"true"},"#"),t(" v2.2.0")],-1),Be={href:"https://github.com/bcgov/NotifyBC/issues/14",target:"_blank",rel:"noopener noreferrer"},Ne=e("li",null,"misc web console adjustments",-1),we=e("li",null,"docs updates",-1),xe=e("h3",{id:"v2-1-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-1-0","aria-hidden":"true"},"#"),t(" v2.1.0")],-1),Ce={href:"https://github.com/bcgov/NotifyBC/issues/13",target:"_blank",rel:"noopener noreferrer"},Se=e("li",null,"misc web console adjustments",-1),Le=e("li",null,"docs updates",-1),Ie=e("h3",{id:"v2-0-0",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#v2-0-0","aria-hidden":"true"},"#"),t(" v2.0.0")],-1),Re=e("li",null,"Runs on LoopBack v4",-1),Je=e("li",null,"All code is converted to TypeScript",-1),je={href:"https://swagger.io/specification/",target:"_blank",rel:"noopener noreferrer"},Ae=e("li",null,"Docs is converted from Jekyll to VuePress",-1),Ve={class:"custom-container tip"},De=e("p",{class:"custom-container-title"},"Why v2?",-1),Ee=e("em",null,"NotifyBC",-1),He={href:"https://loopback.io/",target:"_blank",rel:"noopener noreferrer"},Me=e("em",null,"NotifyBC",-1);function Te(Ge,Ue){const s=i("ExternalLinkIcon"),n=i("RouterLink");return l(),h("div",null,[u,e("p",null,[_,t(" uses "),e("a",p,[t("semantic versioning"),o(s)]),t(".")]),f,m,e("ul",null,[e("li",null,[t("Issue "),e("a",b,[t("#85"),o(s)]),t(": added health check")]),v]),g,e("p",null,[t("See "),o(n,{to:"/docs/upgrade/#v4-to-v5"},{default:r(()=>[t("Upgrade Guide")]),_:1}),t(" for more information.")]),k,e("div",y,[B,e("p",null,[N,t(" was built on "),e("a",w,[t("LoopBack"),o(s)]),t(" since the beginning. While "),x,t(" is an awesome framework at the time, it is evident by 2022 "),C,t(" is no longer actively maintained")]),S]),L,I,e("ul",null,[e("li",null,[t("Issue "),e("a",R,[t("#50"),o(s)]),t(": Email message throttle")]),J,j]),A,e("p",null,[t("See "),o(n,{to:"/docs/upgrade/#v3-to-v4"},{default:r(()=>[t("v3 to v4 upgrade guide")]),_:1}),t(" for more information.")]),e("ul",null,[e("li",null,[t("Issue "),e("a",V,[t("#48"),o(s)]),t(": SMS message throttle")]),D,E,H]),M,T,e("ul",null,[e("li",null,[t("Issue "),e("a",G,[t("#45"),o(s)]),t(": Reliability - Log skipped dispatches for broadcast push notifications")]),U]),W,e("p",null,[t("See "),o(n,{to:"/docs/upgrade/#v2-to-v3"},{default:r(()=>[t("v2 to v3 upgrade guide")]),_:1}),t(" for more information.")]),e("ul",null,[e("li",null,[t("Reliability improvements - issues "),e("a",O,[t("#36"),o(s)]),t(","),e("a",z,[t("#37"),o(s)]),t(","),e("a",P,[t("#38"),o(s)]),t(","),e("a",Q,[t("#39"),o(s)]),t(","),e("a",q,[t("#40"),o(s)]),t(","),e("a",F,[t("#41"),o(s)]),t(","),e("a",K,[t("#42"),o(s)])]),X]),Y,Z,e("ul",null,[e("li",null,[t("Issue "),e("a",$,[t("#34"),o(s)]),t(": Helm - add k8s cronJob to backup MongoDB")]),ee]),te,e("ul",null,[e("li",null,[t("Issue "),e("a",oe,[t("#28"),o(s)]),t(": Allow subscription data be used by mail merge dynamic tokens")]),e("li",null,[t("Issue "),e("a",se,[t("#32"),o(s)]),t(": Allow escape mail merge delimiter")]),ne]),re,e("ul",null,[e("li",null,[t("Issue "),e("a",ie,[t("#26"),o(s)]),t(": Allow filter specified in a notification")]),ae]),le,he,de,e("ul",null,[e("li",null,[t("added "),e("a",ce,[t("helm chart"),o(s)]),t(". See "),o(n,{to:"/docs/miscellaneous/upgrade.html#openshift-template-to-helm"},{default:r(()=>[t("OpenShift template to Helm upgrade guide")]),_:1})]),ue]),_e,e("ul",null,[e("li",null,[t("Issue "),e("a",pe,[t("#16"),o(s)]),t(": Support client certificate authentication")]),fe,me]),be,e("ul",null,[e("li",null,[t("Issue "),e("a",ve,[t("#15"),o(s)]),t(": Support OIDC authentication for both admin and non-admin user")]),ge,ke]),ye,e("ul",null,[e("li",null,[t("Issue "),e("a",Be,[t("#14"),o(s)]),t(": Support Administrator login, changing password, obtain access token in web console")]),Ne,we]),xe,e("ul",null,[e("li",null,[t("Issue "),e("a",Ce,[t("#13"),o(s)]),t(": Upgraded Vuetify from v0.16.9 to v2.4.3")]),Se,Le]),Ie,e("p",null,[t("See "),o(n,{to:"/docs/upgrade/#v1-to-v2"},{default:r(()=>[t("Upgrade Guide")]),_:1}),t(" for more information.")]),e("ul",null,[Re,Je,e("li",null,[t("Upgraded "),e("a",je,[t("OAS"),o(s)]),t(" from v2 to v3")]),Ae]),e("div",Ve,[De,e("p",null,[Ee,t(" has been built on Node.js "),e("a",He,[t("LoopBack"),o(s)]),t(" framework since 2016. LoopBack v4, which was released in 2019, is backward incompatible. To keep software stack up-to-date, unless rewriting from scratch, it is necessary to port "),Me,t(" to LoopBack v4. Great care has been taken to minimize upgrade effort.")])])])}const Oe=a(c,[["render",Te],["__file","index.html.vue"]]);export{Oe as default}; diff --git a/preview/assets/index.html-7d2ae716.js b/preview/assets/index.html-7d2ae716.js new file mode 100644 index 000000000..ecec1a9b2 --- /dev/null +++ b/preview/assets/index.html-7d2ae716.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-14ac19b5","path":"/help/","title":"","lang":"en-US","frontmatter":{},"headers":[{"level":2,"title":"Getting Help","slug":"getting-help","link":"#getting-help","children":[{"level":3,"title":"Documentation","slug":"documentation","link":"#documentation","children":[]},{"level":3,"title":"View source","slug":"view-source","link":"#view-source","children":[]},{"level":3,"title":"Google","slug":"google","link":"#google","children":[]},{"level":3,"title":"Outstanding issues and requests","slug":"outstanding-issues-and-requests","link":"#outstanding-issues-and-requests","children":[]}]}],"git":{},"filePathRelative":"help/index.md"}');export{e as data}; diff --git a/preview/assets/index.html-7dc4890d.js b/preview/assets/index.html-7dc4890d.js new file mode 100644 index 000000000..62ad1ab05 --- /dev/null +++ b/preview/assets/index.html-7dc4890d.js @@ -0,0 +1 @@ +const e=JSON.parse(`{"key":"v-e5065f60","path":"/docs/api-administrator/","title":"Administrator","lang":"en-US","frontmatter":{"permalink":"/docs/api-administrator/"},"headers":[{"level":2,"title":"Model Schemas","slug":"model-schemas","link":"#model-schemas","children":[{"level":3,"title":"Administrator","slug":"administrator-1","link":"#administrator-1","children":[]},{"level":3,"title":"UserCredential","slug":"usercredential","link":"#usercredential","children":[]},{"level":3,"title":"AccessToken","slug":"accesstoken","link":"#accesstoken","children":[]}]},{"level":2,"title":"Sign Up","slug":"sign-up","link":"#sign-up","children":[]},{"level":2,"title":"Login","slug":"login","link":"#login","children":[]},{"level":2,"title":"Set Password","slug":"set-password","link":"#set-password","children":[]},{"level":2,"title":"Get Administrators","slug":"get-administrators","link":"#get-administrators","children":[]},{"level":2,"title":"Get Administrator Count","slug":"get-administrator-count","link":"#get-administrator-count","children":[]},{"level":2,"title":"Delete an Administrator","slug":"delete-an-administrator","link":"#delete-an-administrator","children":[]},{"level":2,"title":"Get an Administrator","slug":"get-an-administrator","link":"#get-an-administrator","children":[]},{"level":2,"title":"Update an Administrator","slug":"update-an-administrator","link":"#update-an-administrator","children":[]},{"level":2,"title":"Replace an Administrator","slug":"replace-an-administrator","link":"#replace-an-administrator","children":[]},{"level":2,"title":"Get an Administrator's AccessTokens","slug":"get-an-administrator-s-accesstokens","link":"#get-an-administrator-s-accesstokens","children":[]},{"level":2,"title":"Update an Administrator's AccessTokens","slug":"update-an-administrator-s-accesstokens","link":"#update-an-administrator-s-accesstokens","children":[]},{"level":2,"title":"Create an Administrator's AccessToken","slug":"create-an-administrator-s-accesstoken","link":"#create-an-administrator-s-accesstoken","children":[]},{"level":2,"title":"Delete an Administrator's AccessTokens","slug":"delete-an-administrator-s-accesstokens","link":"#delete-an-administrator-s-accesstokens","children":[]}],"git":{},"filePathRelative":"docs/api/administrator.md"}`);export{e as data}; diff --git a/preview/assets/index.html-7f453789.js b/preview/assets/index.html-7f453789.js new file mode 100644 index 000000000..3b7cad65d --- /dev/null +++ b/preview/assets/index.html-7f453789.js @@ -0,0 +1,4 @@ +import{_ as s,r as a,o as i,c as o,a as e,b as t,d as r,w as l,e as c}from"./app-73097456.js";const p={},u=c(`

    Internal HTTP Host

    By default, HTTP requests submitted by NotifyBC back to itself will be sent to httpHost if defined or the host of the incoming HTTP request that spawns such internal requests. But if config internalHttpHost, which has no default value, is defined, for example in file /src/config.local.js

    module.exports = {
    +  internalHttpHost: 'http://notifybc:3000',
    +};
    +
    `,3),d=e("em",null,"internalHttpHost",-1),h=e("p",null,[t("All internal requests are supposed to be admin requests. The purpose of "),e("em",null,"internalHttpHost"),t(" is to facilitate identifying the internal server ip as admin ip.")],-1),m=e("div",{class:"custom-container tip"},[e("p",{class:"custom-container-title"},"Kubernetes Use Case"),e("p",null,[t("The Kubernetes deployment script sets "),e("i",null,"internalHttpHost"),t(" to "),e("em",null,"notify-bc-app"),t(" service url in config map. The source ip in such case would be in a private Kubernetes ip range. You should add this private ip range to "),e("a",{href:"#admin-ip-list"},"admin ip list"),t(". The private ip range varies from Kubernetes installation. In BCGov's OCP4 cluster, it starts with octet 10.")])],-1);function f(b,v){const n=a("RouterLink");return i(),o("div",null,[u,e("p",null,[t("then the HTTP request will be sent to the configured host. An internal request can be generated, for example, as a "),r(n,{to:"/docs/config-notification/#broadcast-push-notification-task-concurrency"},{default:l(()=>[t("sub-request of broadcast push notification")]),_:1}),t(". "),d,t(" shouldn't be accessible from internet.")]),h,m])}const g=s(p,[["render",f],["__file","index.html.vue"]]);export{g as default}; diff --git a/preview/assets/index.html-854328d1.js b/preview/assets/index.html-854328d1.js new file mode 100644 index 000000000..fb6e42314 --- /dev/null +++ b/preview/assets/index.html-854328d1.js @@ -0,0 +1,31 @@ +import{_ as t,r as p,o,c as l,a as n,b as s,d as e,e as c}from"./app-73097456.js";const i={},r=n("h1",{id:"middleware",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#middleware","aria-hidden":"true"},"#"),s(" Middleware")],-1),u=n("em",null,"NotifyBC",-1),d={href:"https://expressjs.com/",target:"_blank",rel:"noopener noreferrer"},m=n("em",null,"/src/middleware.ts",-1),k={href:"https://www.npmjs.com/package/compression",target:"_blank",rel:"noopener noreferrer"},v={href:"https://www.npmjs.com/package/helmet",target:"_blank",rel:"noopener noreferrer"},b={href:"https://www.npmjs.com/package/morgan",target:"_blank",rel:"noopener noreferrer"},g=c(`

    /src/middleware.ts contains following default middleware settings

    import path from 'path';
    +module.exports = {
    +  all: {
    +    compression: {},
    +  },
    +  apiOnly: {
    +    helmet: {},
    +    morgan: {
    +      params: [
    +        ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status ":req[X-Forwarded-For]"',
    +      ],
    +      enabled: false,
    +    },
    +  },
    +};
    +

    /src/middleware.ts has following structure

    module.exports = {
    +  all: {
    +    '<middlewareName>': {params: [], enabled: <boolean>},
    +  },
    +  apiOnly: {
    +    '<middlewareName>': {params: [], enabled: <boolean>},
    +  },
    +};
    +

    Middleware defined under all applies to both API and web console requests, as opposed to apiOnly, which applies to API requests only. params are passed to middleware function as arguments. enabled toggles the middleware on or off.

    To change default settings defined in /src/middleware.ts, create file /src/middleware.local.ts or /src/middleware.<env>.ts to override. For example, to enable access log,

    module.exports = {
    +  apiOnly: {
    +    morgan: {
    +      enabled: true,
    +    },
    +  },
    +};
    +
    `,7);function h(_,f){const a=p("ExternalLinkIcon");return o(),l("div",null,[r,n("p",null,[u,s(" pre-installed following "),n("a",d,[s("Express"),e(a)]),s(" middleware as defined in "),m]),n("ul",null,[n("li",null,[n("a",k,[s("compression"),e(a)])]),n("li",null,[n("a",v,[s("helmet"),e(a)])]),n("li",null,[n("a",b,[s("morgan"),e(a)]),s(" (disabled by default)")])]),g])}const y=t(i,[["render",h],["__file","index.html.vue"]]);export{y as default}; diff --git a/preview/assets/index.html-88fcd172.js b/preview/assets/index.html-88fcd172.js new file mode 100644 index 000000000..299f2c6ff --- /dev/null +++ b/preview/assets/index.html-88fcd172.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-587df7db","path":"/docs/config-cronJobs/","title":"Cron Jobs","lang":"en-US","frontmatter":{"permalink":"/docs/config-cronJobs/"},"headers":[{"level":2,"title":"Purge Data","slug":"purge-data","link":"#purge-data","children":[]},{"level":2,"title":"Dispatch Live Notifications","slug":"dispatch-live-notifications","link":"#dispatch-live-notifications","children":[]},{"level":2,"title":"Check Rss Config Updates","slug":"check-rss-config-updates","link":"#check-rss-config-updates","children":[]},{"level":2,"title":"Delete Notification Bounces","slug":"delete-notification-bounces","link":"#delete-notification-bounces","children":[]},{"level":2,"title":"Re-dispatch Broadcast Push Notifications","slug":"re-dispatch-broadcast-push-notifications","link":"#re-dispatch-broadcast-push-notifications","children":[]},{"level":2,"title":"Clear Redis Datastore","slug":"clear-redis-datastore","link":"#clear-redis-datastore","children":[]}],"git":{},"filePathRelative":"docs/config/cronJobs.md"}');export{e as data}; diff --git a/preview/assets/index.html-8a07e160.js b/preview/assets/index.html-8a07e160.js new file mode 100644 index 000000000..5124dfc40 --- /dev/null +++ b/preview/assets/index.html-8a07e160.js @@ -0,0 +1,8 @@ +import{_ as a,r as i,o,c as r,a as e,b as s,d as t,e as p}from"./app-73097456.js";const l={},c=e("h1",{id:"reverse-proxy-ip-lists",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#reverse-proxy-ip-lists","aria-hidden":"true"},"#"),s(" Reverse Proxy IP Lists")],-1),d={href:"https://en.wikipedia.org/wiki/Dot-decimal_notation",target:"_blank",rel:"noopener noreferrer"},u={href:"https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation",target:"_blank",rel:"noopener noreferrer"},h=e("li",null,[e("em",null,"siteMinderReverseProxyIps"),s(" contains a list of ips or ranges of SiteMinder Web Agents. If set, then the SiteMinder HTTP headers are trusted only if the request is routed from the listed nodes.")],-1),m=e("em",null,"trustedReverseProxyIps",-1),f=e("em",null,"NotifyBC",-1),_=e("em",null,"NotifyBC",-1),v={href:"https://expressjs.com/en/guide/behind-proxies.html",target:"_blank",rel:"noopener noreferrer"},k=p(`

    By default trustedReverseProxyIps is empty and siteMinderReverseProxyIps contains only localhost as defined in /src/config.ts

    module.exports = {
    +  siteMinderReverseProxyIps: ['127.0.0.1'],
    +};
    +

    To modify, add following objects to file /src/config.local.js

    module.exports = {
    +  siteMinderReverseProxyIps: ['130.32.12.0'],
    +  trustedReverseProxyIps: ['172.17.0.0/16'],
    +};
    +

    The rule to determine if the incoming request is authenticated by SiteMinder is

    `,5),b={href:"https://expressjs.com/en/guide/behind-proxies.html",target:"_blank",rel:"noopener noreferrer"},g=e("li",null,[s("if the real client ip is contained in "),e("em",null,"siteMinderReverseProxyIps"),s(", then the request is from SiteMinder, and its SiteMinder headers are trusted; otherwise, the request is considered as directly from internet, and its SiteMinder headers are ignored.")],-1);function x(y,S){const n=i("ExternalLinkIcon");return o(),r("div",null,[c,e("p",null,[s("SiteMinder, being a gateway approached SSO solution, expects the backend HTTP access point of the web sites it protests to be firewall restricted, otherwise the SiteMinder injected HTTP headers can be easily spoofed. However, the restriction cannot be easily implemented on PAAS such as OpenShift. To mitigate, two configuration objects are introduced to create an application-level firewall, both are arrays of ip addresses in the format of "),e("a",d,[s("dot-decimal"),t(n)]),s(" or "),e("a",u,[s("CIDR"),t(n)]),s(" notation")]),e("ul",null,[h,e("li",null,[m,s(" contains a list of ips or ranges of trusted reverse proxies. If "),f,s(" is placed behind SiteMinder Web Agents, then trusted reverse proxies should include only those between SiteMinder Web Agents and "),_,s(" application. When running on OpenShift, this is usually the OpenShift router. Express.js "),e("a",v,[s("trust proxy"),t(n)]),s(" is set to this config object.")])]),k,e("ol",null,[e("li",null,[s("obtain the real client ip address by filtering out trusted proxy ips according to "),e("a",b,[s("Express behind proxies"),t(n)])]),g])])}const M=a(l,[["render",x],["__file","index.html.vue"]]);export{M as default}; diff --git a/preview/assets/index.html-8ad94fae.js b/preview/assets/index.html-8ad94fae.js new file mode 100644 index 000000000..a73b81259 --- /dev/null +++ b/preview/assets/index.html-8ad94fae.js @@ -0,0 +1,14 @@ +import{_ as o,r as i,o as p,c,a as e,b as n,d as a,w as t,e as r}from"./app-73097456.js";const l={},d=e("h1",{id:"rsa-keys",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#rsa-keys","aria-hidden":"true"},"#"),n(" RSA Keys")],-1),u=e("em",null,"NotifyBC",-1),h=e("em",null,"cURL",-1),k=r(`
    curl -X GET 'http://localhost:3000/api/configurations?filter=%7B%22where%22%3A%20%7B%22name%22%3A%20%22rsa%22%7D%7D'
    +

    or you can open API explorer, expand GET /configurations and set filter to

    {"where": {"name": "rsa"}}
    +

    The response should be something like

    [
    +  {
    +    "name": "rsa",
    +    "value": {
    +      "private": "-----BEGIN RSA PRIVATE KEY-----\\nMIIEpgIBAAKCAQEA8Hl+/cF3AOxKVRHtZpeSDM+LLGc2hkDkKxRXe72maUAzDUoO\\noNd6wd02Cf6iO7kj0RSDHXUyINxCgvXy2Q7gME4zRN5WG4ItWZ7FITeNgJJW1r+J\\nshDjTwKVpMvcKHy0vyUl25ah7hnwGK6PbJvFWMmtIBw6Rs5DaERAlmilgkuUgdri\\naA4YhhS4pCJLvO2p9wZd+dLWUT+tpsOZGeecC8If3fyShgrocMbd8pYYDzf65oCt\\nVaLaNdERaIJSDcmbHxFpeBdEQEzxw2qRPbUCnSgQb8cVFLJ2eOEn5LylWhU96A1S\\n3w1IlRm5N2zG0En58Vruo26gEtl5KFu0zivlawIDAQABAoIBAQCAawFsFcKtVYIk\\nh9xVax/tg2/5GG0/qKuwbb6CMDcMAeLBeAjzz96YZL+U+sw8RJRh9ShHtOw+LCHA\\nugMj8xO5+Cjc4DbvnccGEwmGwZnpTTzelY687tPUv7aWON+rJ12GrhnXeEulUWis\\nZZvmDhGHZrvzZ9+fLEtHBRvQtrWcLCN0G5l1Z1KEWUj23vn1HZpfNvqigIbC05Pq\\nWUewRZShHUklhzky6DwLklWUKv2951ypd5CHhYfXn0eXjeyqcoYeZzoCSGqtvZar\\nVVOCPBKPn3cLZVKzYd02WO4CV07SpHCBtYPWf4OvXbOY6wV1Vc92S0K+ijASDDc0\\nB7Vjgb8RAoGBAPg4dSbn9GWNHydveidi2Zt4kftEW18C9xHbJ3t+QkhpLjq2kwcY\\no0iOWkEd4d1l0lKAVanBQazrazKiSyq1PJSJDyL3osHItA7Twq+gCXOfXw/0LbJh\\napK5DH3S2ZTM42wOdZLYIHvSqRuYUmnzhy9+Ads87b/ICCctUMCLz1afAoGBAPgC\\n4/zE/Au/A3wb48AywfmJ5kqPO0V7lqLrn/aBwdF1H/DHQ95cSuKrTEIysZxz52bh\\n7mAHjnWnY4zFNaUvcruHw78NOxUJvje8cDIUsrTefh+qmctiGR119z7iso9FlsxR\\np/o5BVT/K8q76xtkpOln2A0rc8sBNwtCoeeUzfm1AoGBAInH/O99raF49iQTswCN\\n1DCCerW4uedBZBebSI06BlzfVXPtyCsWN/ycV+jxR2B3lomJBwPVbDkp7DUM9SBd\\nvaTNd4N3ZfafC6N3VAfck6KEgmX+qibsABY1dYOaOIBqQorGc+jw4wcYZhoVMRny\\nvcVU8n7ZkTb1N+FXPA3FDXANAoGBAOuSg0/TI71cgEjgjOJA1DLco1vq1NfY3mp9\\n+QFCmwEDiYVBINwTOiY3o0W1tTLwfLoinDOmudBTYKGTqLLwcMBj4rCUNqxzBrUW\\nTlOjiWN3esFFYLPoyAZNyL14wzaHWQdWAIISq1fi0IvPFzB71pDFTFimD2SiENCn\\nR/YaR9OJAoGBAM21MRvTEMHF/EvqZ/X6t2zm9dtA22L2LeVy68aEdo82F/1RFvCM\\nGBWjGS7G7fXk/tV/YHbjibhgktvLu3Rss1wlHfGEjtDAIdp9dqH0cNxMgy/eTfoy\\nFfzV3l7pNSdILn1bNqoMz9CaYK7CGIYpBWCbRJlRSYw2FHJwl5tzgmkk\\n-----END RSA PRIVATE KEY-----",
    +      "public": "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Hl+/cF3AOxKVRHtZpeS\\nDM+LLGc2hkDkKxRXe72maUAzDUoOoNd6wd02Cf6iO7kj0RSDHXUyINxCgvXy2Q7g\\nME4zRN5WG4ItWZ7FITeNgJJW1r+JshDjTwKVpMvcKHy0vyUl25ah7hnwGK6PbJvF\\nWMmtIBw6Rs5DaERAlmilgkuUgdriaA4YhhS4pCJLvO2p9wZd+dLWUT+tpsOZGeec\\nC8If3fyShgrocMbd8pYYDzf65oCtVaLaNdERaIJSDcmbHxFpeBdEQEzxw2qRPbUC\\nnSgQb8cVFLJ2eOEn5LylWhU96A1S3w1IlRm5N2zG0En58Vruo26gEtl5KFu0zivl\\nawIDAQAB\\n-----END PUBLIC KEY-----"
    +    },
    +    "id": "591cda5d6c7adec42a1874bc",
    +    "updated": "2017-05-17T23:18:53.385Z"
    +  }
    +]
    +

    The public key is the string -----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----

    `,6),v=e("div",{class:"custom-container danger"},[e("p",{class:"custom-container-title"},"Expose RSA public key to only trusted party"),e("p",null,"Despite of the adjective public, NotifyBC's public key should only be distributed to trusted third party. The trusted third party should only use the public key at server backend. Using the public key in client-side JavaScript poses a security loophole.")],-1);function b(m,g){const s=i("RouterLink");return p(),c("div",null,[d,e("p",null,[n("When "),u,n(" starts up, it checks if an RSA key pair exists in database as dynamic config. If not it will generate the dynamic config and save it to database. This RSA key pair is used to exchange confidential information with third party server applications through user's browser. For an example of use case, see "),a(s,{to:"/docs/api-subscription/"},{default:t(()=>[n("Subscription API")]),_:1}),n(". To make it work, send the public key to the third party and have their server app encrypt the data using the public key. To obtain public key, call the REST "),a(s,{to:"/docs/config/..api-config/#get-configurations"},{default:t(()=>[n("Configuration API")]),_:1}),n(" from an admin ip, for example, by running "),h,n(" command")]),k,e("p",null,[n("In a multi-node deployment, when the cluster is first started up, database is empty and rsa key pair doesn't exist. To prevent multiple rsa keys being generated by different nodes, only the "),a(s,{to:"/docs/config-nodeRoles/"},{default:t(()=>[n("master node")]),_:1}),n(" can generate the rsa key pair. other nodes will wait for the key pair available in database before proceeding with rest bootstrap.")]),v])}const A=o(l,[["render",b],["__file","index.html.vue"]]);export{A as default}; diff --git a/preview/assets/index.html-8f681a20.js b/preview/assets/index.html-8f681a20.js new file mode 100644 index 000000000..2903c5970 --- /dev/null +++ b/preview/assets/index.html-8f681a20.js @@ -0,0 +1,4 @@ +import{_ as e,o as t,c as s,e as n}from"./app-73097456.js";const a={},o=n(`

    HTTP Host

    httpHost config sets the fallback http host used by

    • mail merge token substitution
    • internal HTTP requests spawned by NotifyBC

    httpHost can be overridden by other configs or data. For example

    • internalHttpHost config
    • httpHost field in a notification

    There are contexts where there is no alternatives to httpHost. Therefore this config should be defined.

    Define the config, which has no default value, in /src/config.local.js

    module.exports = {
    +  httpHost: 'http://foo.com',
    +};
    +
    `,8),i=[o];function l(p,c){return t(),s("div",null,i)}const d=e(a,[["render",l],["__file","index.html.vue"]]);export{d as default}; diff --git a/preview/assets/index.html-9094b141.js b/preview/assets/index.html-9094b141.js new file mode 100644 index 000000000..a9a4966fe --- /dev/null +++ b/preview/assets/index.html-9094b141.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-5b6d532c","path":"/docs/memory-dump/","title":"Memory Dump","lang":"en-US","frontmatter":{"permalink":"/docs/memory-dump/"},"headers":[],"git":{},"filePathRelative":"docs/miscellaneous/memory-dump.md"}');export{e as data}; diff --git a/preview/assets/index.html-93ff6c80.js b/preview/assets/index.html-93ff6c80.js new file mode 100644 index 000000000..7ce893d62 --- /dev/null +++ b/preview/assets/index.html-93ff6c80.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-828730c2","path":"/docs/api-config/","title":"Configuration","lang":"en-US","frontmatter":{"permalink":"/docs/api-config/"},"headers":[{"level":2,"title":"Model Schema","slug":"model-schema","link":"#model-schema","children":[]},{"level":2,"title":"Get Configurations","slug":"get-configurations","link":"#get-configurations","children":[]},{"level":2,"title":"Create a Configuration","slug":"create-a-configuration","link":"#create-a-configuration","children":[]},{"level":2,"title":"Update a Configuration","slug":"update-a-configuration","link":"#update-a-configuration","children":[]},{"level":2,"title":"Update Configurations","slug":"update-configurations","link":"#update-configurations","children":[]},{"level":2,"title":"Delete a Configuration","slug":"delete-a-configuration","link":"#delete-a-configuration","children":[]},{"level":2,"title":"Replace a Configuration","slug":"replace-a-configuration","link":"#replace-a-configuration","children":[]}],"git":{},"filePathRelative":"docs/api/config.md"}');export{e as data}; diff --git a/preview/assets/index.html-94f1d832.js b/preview/assets/index.html-94f1d832.js new file mode 100644 index 000000000..adef23b98 --- /dev/null +++ b/preview/assets/index.html-94f1d832.js @@ -0,0 +1 @@ +import{_ as r,r as a,o as s,c as n,a as t,d as o,w as c,b as e,e as i}from"./app-73097456.js";const l={},p=t("h1",{id:"bounce",tabindex:"-1"},[t("a",{class:"header-anchor",href:"#bounce","aria-hidden":"true"},"#"),e(" Bounce")],-1),u=i('

    Model Schema

    The API operates on following data model fields:

    NameAttributes

    channel

    name of the delivery channel. Valid values: email, sms.

    typestring
    requiredtrue

    userChannelId

    user's delivery channel id, for example, email address.
    typestring
    requiredtrue

    hardBounceCount

    number of hard bounces recorded so far

    typeinteger
    requiredtrue

    state

    bounce record state. Valid values: active, deleted.

    typestring
    requiredtrue

    bounceMessages

    array of recorded bounce messages. Each element is an object containing the date bounce message was received and the message itself.

    typearray
    requiredfalse

    latestNotificationStarted

    latest notification started date.

    typedate
    requiredfalse

    latestNotificationEnded

    latest notification ended date.

    typedate
    requiredfalse

    created

    date and time bounce record was created

    typedate
    auto-generatedtrue

    updated

    date and time of bounce record was last updated

    typedate
    auto-generatedtrue

    id

    config id

    typestring, format depends on db
    auto-generatedtrue
    ',3);function m(b,h){const d=a("RouterLink");return s(),n("div",null,[p,t("p",null,[o(d,{to:"/docs/config/email.html#bounce"},{default:c(()=>[e("Bounce")]),_:1}),e(" handling involves recording bounce messages into bounce records, which are implemented using this bounce API and model. Administrator can view bounce records in web console or through API explorer. Bounce record is for internal use and should be read-only under normal circumstances.")]),u])}const g=r(l,[["render",m],["__file","index.html.vue"]]);export{g as default}; diff --git a/preview/assets/index.html-963a4ecf.js b/preview/assets/index.html-963a4ecf.js new file mode 100644 index 000000000..680f8ef04 --- /dev/null +++ b/preview/assets/index.html-963a4ecf.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-255f131a","path":"/docs/installation/","title":"Installation","lang":"en-US","frontmatter":{"permalink":"/docs/installation/"},"headers":[{"level":2,"title":"Deploy locally from Source Code","slug":"deploy-locally-from-source-code","link":"#deploy-locally-from-source-code","children":[{"level":3,"title":"System Requirements","slug":"system-requirements","link":"#system-requirements","children":[]},{"level":3,"title":"Installation","slug":"installation-1","link":"#installation-1","children":[]}]},{"level":2,"title":"Deploy to Kubernetes","slug":"deploy-to-kubernetes","link":"#deploy-to-kubernetes","children":[{"level":3,"title":"Customizations","slug":"customizations","link":"#customizations","children":[]}]},{"level":2,"title":"Deploy Docker Container","slug":"deploy-docker-container","link":"#deploy-docker-container","children":[]}],"git":{},"filePathRelative":"docs/getting-started/installation.md"}');export{e as data}; diff --git a/preview/assets/index.html-a08056c0.js b/preview/assets/index.html-a08056c0.js new file mode 100644 index 000000000..1d718b87d --- /dev/null +++ b/preview/assets/index.html-a08056c0.js @@ -0,0 +1,6 @@ +import{_ as a,r as o,o as t,c as p,a as e,b as n,d as r,e as c}from"./app-73097456.js";const l={},i=c(`

    Database

    By default NotifyBC uses in-memory database backed up by folder /server/database/ for local and docker deployment and MongoDB for Kubernetes deployment. To use MongoDB for non-Kubernetes deployment, add file /src/datasources/db.datasource.(local|<env>).(json|js|ts) with MongoDB connection information such as following:

    module.exports = {
    +  uri: 'mongodb://127.0.0.1:27017/notifyBC?replicaSet=rs0',
    +  user: process.env.MONGODB_USER,
    +  pass: process.env.MONGODB_PASSWORD,
    +};
    +
    `,3),d={href:"https://mongoosejs.com/docs/connections.html#options",target:"_blank",rel:"noopener noreferrer"};function u(m,k){const s=o("ExternalLinkIcon");return t(),p("div",null,[i,e("p",null,[n("See "),e("a",d,[n("Mongoose connection options"),r(s)]),n(" for more configurable properties.")])])}const b=a(l,[["render",u],["__file","index.html.vue"]]);export{b as default}; diff --git a/preview/assets/index.html-a0e9a4ad.js b/preview/assets/index.html-a0e9a4ad.js new file mode 100644 index 000000000..8c129ab13 --- /dev/null +++ b/preview/assets/index.html-a0e9a4ad.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-6a4de75f","path":"/docs/quickstart/","title":"Quick Start","lang":"en-US","frontmatter":{"permalink":"/docs/quickstart/"},"headers":[],"git":{},"filePathRelative":"docs/getting-started/quickstart.md"}');export{t as data}; diff --git a/preview/assets/index.html-a223bf40.js b/preview/assets/index.html-a223bf40.js new file mode 100644 index 000000000..05fc57bf0 --- /dev/null +++ b/preview/assets/index.html-a223bf40.js @@ -0,0 +1 @@ +import{_ as e,o as _,c as t}from"./app-73097456.js";const n={};function c(o,r){return _(),t("div")}const l=e(n,[["render",c],["__file","index.html.vue"]]);export{l as default}; diff --git a/preview/assets/index.html-a2d4ab5c.js b/preview/assets/index.html-a2d4ab5c.js new file mode 100644 index 000000000..4b86dc9d4 --- /dev/null +++ b/preview/assets/index.html-a2d4ab5c.js @@ -0,0 +1 @@ +const a=JSON.parse('{"key":"v-0b2aad78","path":"/docs/config-database/","title":"Database","lang":"en-US","frontmatter":{"permalink":"/docs/config-database/"},"headers":[],"git":{},"filePathRelative":"docs/config/database.md"}');export{a as data}; diff --git a/preview/assets/index.html-a48fd168.js b/preview/assets/index.html-a48fd168.js new file mode 100644 index 000000000..f64caf855 --- /dev/null +++ b/preview/assets/index.html-a48fd168.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-0c9564ec","path":"/docs/developer-notes/","title":"Developer Notes","lang":"en-US","frontmatter":{"permalink":"/docs/developer-notes/"},"headers":[{"level":2,"title":"Setup development environment","slug":"setup-development-environment","link":"#setup-development-environment","children":[]},{"level":2,"title":"Automated Testing","slug":"automated-testing","link":"#automated-testing","children":[{"level":3,"title":"Writing Test Specs","slug":"writing-test-specs","link":"#writing-test-specs","children":[]}]},{"level":2,"title":"Install Docs Website","slug":"install-docs-website","link":"#install-docs-website","children":[]},{"level":2,"title":"Publish Version Checklist","slug":"publish-version-checklist","link":"#publish-version-checklist","children":[]}],"git":{},"filePathRelative":"docs/miscellaneous/developer-notes.md"}');export{e as data}; diff --git a/preview/assets/index.html-a70b6cf1.js b/preview/assets/index.html-a70b6cf1.js new file mode 100644 index 000000000..405feefee --- /dev/null +++ b/preview/assets/index.html-a70b6cf1.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-3481b484","path":"/docs/config-nodeRoles/","title":"Node Roles","lang":"en-US","frontmatter":{"permalink":"/docs/config-nodeRoles/"},"headers":[],"git":{},"filePathRelative":"docs/config/nodeRoles.md"}');export{e as data}; diff --git a/preview/assets/index.html-a7337040.js b/preview/assets/index.html-a7337040.js new file mode 100644 index 000000000..107fc17f1 --- /dev/null +++ b/preview/assets/index.html-a7337040.js @@ -0,0 +1 @@ +const i=JSON.parse('{"key":"v-fe45a0b8","path":"/docs/api-subscription/","title":"Subscription","lang":"en-US","frontmatter":{"permalink":"/docs/api-subscription/"},"headers":[{"level":2,"title":"Model Schema","slug":"model-schema","link":"#model-schema","children":[]},{"level":2,"title":"Get Subscriptions","slug":"get-subscriptions","link":"#get-subscriptions","children":[]},{"level":2,"title":"Get Subscription Count","slug":"get-subscription-count","link":"#get-subscription-count","children":[]},{"level":2,"title":"Create a Subscription","slug":"create-a-subscription","link":"#create-a-subscription","children":[]},{"level":2,"title":"Verify a Subscription","slug":"verify-a-subscription","link":"#verify-a-subscription","children":[]},{"level":2,"title":"Update a Subscription","slug":"update-a-subscription","link":"#update-a-subscription","children":[]},{"level":2,"title":"Delete a Subscription (unsubscribing)","slug":"delete-a-subscription-unsubscribing","link":"#delete-a-subscription-unsubscribing","children":[]},{"level":2,"title":"Un-deleting a Subscription","slug":"un-deleting-a-subscription","link":"#un-deleting-a-subscription","children":[]},{"level":2,"title":"Get all services with confirmed subscribers","slug":"get-all-services-with-confirmed-subscribers","link":"#get-all-services-with-confirmed-subscribers","children":[]},{"level":2,"title":"Replace a Subscription","slug":"replace-a-subscription","link":"#replace-a-subscription","children":[]}],"git":{},"filePathRelative":"docs/api/subscription.md"}');export{i as data}; diff --git a/preview/assets/index.html-a9eb55a1.js b/preview/assets/index.html-a9eb55a1.js new file mode 100644 index 000000000..7542f703d --- /dev/null +++ b/preview/assets/index.html-a9eb55a1.js @@ -0,0 +1 @@ +const i=JSON.parse('{"key":"v-b6a1f058","path":"/docs/config-notification/","title":"Notification","lang":"en-US","frontmatter":{"permalink":"/docs/config-notification/"},"headers":[{"level":2,"title":"RSS Feeds","slug":"rss-feeds","link":"#rss-feeds","children":[]},{"level":2,"title":"Broadcast Push Notification Task Concurrency","slug":"broadcast-push-notification-task-concurrency","link":"#broadcast-push-notification-task-concurrency","children":[]},{"level":2,"title":"Broadcast Push Notification Custom Filter Functions","slug":"broadcast-push-notification-custom-filter-functions","link":"#broadcast-push-notification-custom-filter-functions","children":[]},{"level":2,"title":"Guaranteed Broadcast Push Dispatch Processing","slug":"guaranteed-broadcast-push-dispatch-processing","link":"#guaranteed-broadcast-push-dispatch-processing","children":[{"level":3,"title":"Also log skipped dispatches for broadcast push notifications","slug":"also-log-skipped-dispatches-for-broadcast-push-notifications","link":"#also-log-skipped-dispatches-for-broadcast-push-notifications","children":[]}]}],"git":{},"filePathRelative":"docs/config/notification.md"}');export{i as data}; diff --git a/preview/assets/index.html-b4c829a8.js b/preview/assets/index.html-b4c829a8.js new file mode 100644 index 000000000..a228a6e9b --- /dev/null +++ b/preview/assets/index.html-b4c829a8.js @@ -0,0 +1 @@ +import{_ as e,o as i,c as a,e as o}from"./app-73097456.js";const t={},n=o('

    API Overview

    NotifyBC's core function is implemented by two models - subscription and notification. Other models - configuration, administrator and bounces etc, are for administrative purposes. A model determines the underlying database schema and the API. The APIs displayed in the web console (by default http://localhost:3000) and API explorer are also grouped by models. Click on a model in API explorer, say notification, to explore the operations on that model. Model specific APIs are available here:

    ',3),r=[n];function l(s,c){return i(),a("div",null,r)}const h=e(t,[["render",l],["__file","index.html.vue"]]);export{h as default}; diff --git a/preview/assets/index.html-bc520f9b.js b/preview/assets/index.html-bc520f9b.js new file mode 100644 index 000000000..222cf768d --- /dev/null +++ b/preview/assets/index.html-bc520f9b.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-32b5e2dd","path":"/docs/config-reverseProxyIpLists/","title":"Reverse Proxy IP Lists","lang":"en-US","frontmatter":{"permalink":"/docs/config-reverseProxyIpLists/"},"headers":[],"git":{},"filePathRelative":"docs/config/reverseProxyIpLists.md"}');export{e as data}; diff --git a/preview/assets/index.html-bca8604a.js b/preview/assets/index.html-bca8604a.js new file mode 100644 index 000000000..9b683b667 --- /dev/null +++ b/preview/assets/index.html-bca8604a.js @@ -0,0 +1,3 @@ +import{_ as e,o,c as n,e as a}from"./app-73097456.js";const s={},t=a(`

    Memory Dump

    Super-admin can get a memory dump of NotifyBC instance by querying /memory API end point. For example

    $ curl -s http://localhost:3000/api/memory
    +Heap.20240513.114015.22037.0.001.heapsnapshot
    +

    The output is the file name of the memory dump. The dump file can be loaded by, for example, Chrome DevTools.

    How to get memory dump of a particular node?

    If you call /memory from a client-facing URL end point, which is usually load-balanced, the memory dump occurs only on node handling your request. To perform it on the node you want to troubleshoot, in particular the master node, run the command from the node. Make sure 127.0.0.1 is in adminIps.

    `,5),m=[t];function r(c,d){return o(),n("div",null,m)}const l=e(s,[["render",r],["__file","index.html.vue"]]);export{l as default}; diff --git a/preview/assets/index.html-c46be575.js b/preview/assets/index.html-c46be575.js new file mode 100644 index 000000000..fa614bf83 --- /dev/null +++ b/preview/assets/index.html-c46be575.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-17bdcfe6","path":"/docs/config-middleware/","title":"Middleware","lang":"en-US","frontmatter":{"permalink":"/docs/config-middleware/"},"headers":[],"git":{},"filePathRelative":"docs/config/middleware.md"}');export{e as data}; diff --git a/preview/assets/index.html-c67caa0a.js b/preview/assets/index.html-c67caa0a.js new file mode 100644 index 000000000..28b3e1eff --- /dev/null +++ b/preview/assets/index.html-c67caa0a.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-36e2ae9d","path":"/docs/health-check/","title":"Health Check","lang":"en-US","frontmatter":{"permalink":"/docs/health-check/"},"headers":[],"git":{},"filePathRelative":"docs/miscellaneous/health-check.md"}');export{e as data}; diff --git a/preview/assets/index.html-caa38aac.js b/preview/assets/index.html-caa38aac.js new file mode 100644 index 000000000..07b50416e --- /dev/null +++ b/preview/assets/index.html-caa38aac.js @@ -0,0 +1 @@ +const i=JSON.parse('{"key":"v-6165843c","path":"/docs/config-subscription/","title":"Subscription","lang":"en-US","frontmatter":{"permalink":"/docs/config-subscription/"},"headers":[{"level":2,"title":"Confirmation Request Message","slug":"confirmation-request-message","link":"#confirmation-request-message","children":[]},{"level":2,"title":"Confirmation Verification Acknowledgement Messages","slug":"confirmation-verification-acknowledgement-messages","link":"#confirmation-verification-acknowledgement-messages","children":[]},{"level":2,"title":"Duplicated Subscription","slug":"duplicated-subscription","link":"#duplicated-subscription","children":[]},{"level":2,"title":"Anonymous Unsubscription","slug":"anonymous-unsubscription","link":"#anonymous-unsubscription","children":[]}],"git":{},"filePathRelative":"docs/config/subscription.md"}');export{i as data}; diff --git a/preview/assets/index.html-ccd9b97c.js b/preview/assets/index.html-ccd9b97c.js new file mode 100644 index 000000000..cfdaa1d54 --- /dev/null +++ b/preview/assets/index.html-ccd9b97c.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-b341ee2c","path":"/docs/bulk-import/","title":"Bulk Import","lang":"en-US","frontmatter":{"permalink":"/docs/bulk-import/"},"headers":[{"level":3,"title":"Field Parsers","slug":"field-parsers","link":"#field-parsers","children":[]}],"git":{},"filePathRelative":"docs/miscellaneous/bulk-import.md"}');export{e as data}; diff --git a/preview/assets/index.html-ced62edd.js b/preview/assets/index.html-ced62edd.js new file mode 100644 index 000000000..f3564ef36 --- /dev/null +++ b/preview/assets/index.html-ced62edd.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-326db923","path":"/docs/config-certificates/","title":"TLS Certificates","lang":"en-US","frontmatter":{"permalink":"/docs/config-certificates/","next":"/docs/api-overview/"},"headers":[{"level":2,"title":"Client certificate authentication","slug":"client-certificate-authentication","link":"#client-certificate-authentication","children":[]}],"git":{},"filePathRelative":"docs/config/certificates.md"}');export{t as data}; diff --git a/preview/assets/index.html-d1326e32.js b/preview/assets/index.html-d1326e32.js new file mode 100644 index 000000000..b1e04a161 --- /dev/null +++ b/preview/assets/index.html-d1326e32.js @@ -0,0 +1,30 @@ +import{_ as n,o as s,c as a,e as t}from"./app-73097456.js";const o={},e=t(`

    Health Check

    Health status of NotifyBC can be obtained by querying /health API end point. For example

    $ curl -s http://localhost:3000/api/health | jq
    +{
    +  "status": "ok",
    +  "info": {
    +    "MongoDB": {
    +      "status": "up"
    +    },
    +    "config": {
    +      "status": "up",
    +      "count": 2
    +    },
    +    "redis": {
    +      "status": "up"
    +    }
    +  },
    +  "error": {},
    +  "details": {
    +    "MongoDB": {
    +      "status": "up"
    +    },
    +    "config": {
    +      "status": "up",
    +      "count": 2
    +    },
    +    "redis": {
    +      "status": "up"
    +    }
    +  }
    +}
    +

    If overall health status is OK, the HTTP response code is 200, otherwise 503. The response payload shows status of following indicators and health criteria

    1. MongoDB - MongoDB must be reachable
    2. config - There must be at least 2 items in MongoDB configuration collection
    3. Redis - Redis must be reachable if configured

    /health API end point is also reachable in API Explorer of NotifyBC web console.

    `,6),p=[e];function c(l,i){return s(),a("div",null,p)}const r=n(o,[["render",c],["__file","index.html.vue"]]);export{r as default}; diff --git a/preview/assets/index.html-ddb984f8.js b/preview/assets/index.html-ddb984f8.js new file mode 100644 index 000000000..f1a822601 --- /dev/null +++ b/preview/assets/index.html-ddb984f8.js @@ -0,0 +1,34 @@ +import{_ as s,r as l,o as r,c as o,a as e,b as t,d as n,e as i}from"./app-73097456.js";const d={},p=i(`

    Configuration

    The configuration API, accessible by only super-admin requests, is used to define dynamic configurations. Dynamic configuration is needed in situations like

    • RSA key pair generated automatically at boot time if not present
    • service-specific subscription confirmation request message template

    Model Schema

    The API operates on following configuration data model fields:

    NameAttributes

    id

    config id

    typestring, format depends on db
    auto-generatedtrue

    name

    config name

    typestring
    requiredtrue

    value

    config value.
    typeobject
    requiredtrue

    serviceName

    name of the service the config applicable to

    typestring
    requiredfalse

    Get Configurations

    GET /configurations
    +
    `,8),u=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),c=e("p",null,"inputs",-1),m=i("

    a filter containing properties where, fields, order, skip, and limit

    • parameter name: filter
    • required: false
    • parameter type: query
    • data type: object

    The filter can be expressed as either

    ",3),h=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),f={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},g=e("code",null,'?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"',-1),b=i(`

    Regardless, the filter will have to be parsed into a JSON object conforming to

    {
    +    "where": {...},
    +    "fields": ...,
    +    "order": ...,
    +    "skip": ...,
    +    "limit": ...,
    +}
    +

    All properties are optional. The syntax for each property is documented, respectively

    `,3),v=e("em",null,"where",-1),_={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},k=e("em",null,"fields",-1),q={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.select()",target:"_blank",rel:"noopener noreferrer"},y=e("em",null,"order",-1),x={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},w=e("em",null,"skip",-1),j={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.skip/",target:"_blank",rel:"noopener noreferrer"},T=e("em",null,"limit",-1),A={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.limit/",target:"_blank",rel:"noopener noreferrer"},C=i(`
  • outcome

    For admin request, a list of config items matching the filter; forbidden for user request

  • example

    to retrieve configs created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/configurations?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
    +

    the value of the filter query parameter is URL-encoded stringified JSON object

    {
    +  "where": {
    +    "created": {
    +      "$gte": "2023-01-01",
    +      "$lt": "2024-01-01"
    +    }
    +  }
    +}
    +
  • `,2),S=i(`

    Create a Configuration

    POST /configurations
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • an object containing configuration data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated. The API explorer only created an empty object for field value but you should populate the child fields.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC performs following actions in sequence

      1. if it’s a user request, error is returned
      2. inputs are validated. For example, required fields without default values must be populated. If validation fails, error is returned
      3. if config item is notification with field value.rss populated, and if the field value.httpHost is missing, it is generated using this request's HTTP protocol , host name and port.
      4. item is saved to database

    Update a Configuration

    PATCH /configurations/{id}
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      Similar to POST except field update is always updated with current timestamp.

    Update Configurations

    PATCH /configurations
    +
    `,9),D=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin")])],-1),N=e("p",null,"inputs",-1),B=e("em",null,"where",-1),P={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},E=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),O=e("p",null,"The value can be expressed as either",-1),I=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),L={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},R=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),U=e("li",null,[e("p",null,"an object containing fields to be updated."),e("ul",null,[e("li",null,"required: true"),e("li",null,"parameter type: body"),e("li",null,"data type: object")])],-1),$=i(`
  • outcome

    Similar to POST except field update is always updated with current timestamp.

  • example

    to set serviceName to myService for all configs created in year 2023 , run

    curl -X PATCH --header 'Content-Type: application/json' 'http://localhost:3000/api/configurations?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D' -d @- << EOF
    +{
    +  "serviceName": "myService",
    +}
    +EOF
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2),M=i(`

    Delete a Configuration

    DELETE /configurations/{id}
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      For admin request, delete the config item requested; forbidden for user request

    Replace a Configuration

    PUT /configurations/{id}
    +

    This API is intended to be only used by admin web console to modify a configuration.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • configuration data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      For admin requests, replace configuration identified by id with parameter data and save to database.

    `,7);function F(H,J){const a=l("ExternalLinkIcon");return r(),o("div",null,[p,e("ul",null,[u,e("li",null,[c,e("ul",null,[e("li",null,[m,e("ol",null,[h,e("li",null,[t("in the format supported by "),e("a",f,[t("qs"),n(a)]),t(", for example "),g])]),b,e("ul",null,[e("li",null,[t("for "),v,t(" , see MongoDB "),e("a",_,[t("Query Documents"),n(a)])]),e("li",null,[t("for "),k,t(" , see Mongoose "),e("a",q,[t("select"),n(a)])]),e("li",null,[t("for "),y,t(", see Mongoose "),e("a",x,[t("sort"),n(a)])]),e("li",null,[t("for "),w,t(", see MongoDB "),e("a",j,[t("cursor.skip"),n(a)])]),e("li",null,[t("for "),T,t(", see MongoDB "),e("a",A,[t("cursor.limit"),n(a)])])])])])]),C]),S,e("ul",null,[D,e("li",null,[N,e("ul",null,[e("li",null,[e("p",null,[t("a "),B,t(" query parameter with value conforming to MongoDB "),e("a",P,[t("Query Documents"),n(a)])]),E,O,e("ol",null,[I,e("li",null,[t("in the format supported by "),e("a",L,[t("qs"),n(a)]),t(", for example "),R])])]),U])]),$]),M])}const V=s(d,[["render",F],["__file","index.html.vue"]]);export{V as default}; diff --git a/preview/assets/index.html-e1863c04.js b/preview/assets/index.html-e1863c04.js new file mode 100644 index 000000000..73d83a68a --- /dev/null +++ b/preview/assets/index.html-e1863c04.js @@ -0,0 +1 @@ +const l=JSON.parse(`{"key":"v-9a955b1e","path":"/docs/what's-new/","title":"What's New","lang":"en-US","frontmatter":{"permalink":"/docs/what's-new/","next":"/docs/config-overview/"},"headers":[{"level":2,"title":"v5","slug":"v5","link":"#v5","children":[{"level":3,"title":"v5.1.0","slug":"v5-1-0","link":"#v5-1-0","children":[]},{"level":3,"title":"v5.0.0","slug":"v5-0-0","link":"#v5-0-0","children":[]}]},{"level":2,"title":"v4","slug":"v4","link":"#v4","children":[{"level":3,"title":"v4.1.0","slug":"v4-1-0","link":"#v4-1-0","children":[]},{"level":3,"title":"v4.0.0","slug":"v4-0-0","link":"#v4-0-0","children":[]}]},{"level":2,"title":"v3","slug":"v3","link":"#v3","children":[{"level":3,"title":"v3.1.0","slug":"v3-1-0","link":"#v3-1-0","children":[]},{"level":3,"title":"v3.0.0","slug":"v3-0-0","link":"#v3-0-0","children":[]}]},{"level":2,"title":"v2","slug":"v2","link":"#v2","children":[{"level":3,"title":"v2.9.0","slug":"v2-9-0","link":"#v2-9-0","children":[]},{"level":3,"title":"v2.8.0","slug":"v2-8-0","link":"#v2-8-0","children":[]},{"level":3,"title":"v2.7.0","slug":"v2-7-0","link":"#v2-7-0","children":[]},{"level":3,"title":"v2.6.0","slug":"v2-6-0","link":"#v2-6-0","children":[]},{"level":3,"title":"v2.5.0","slug":"v2-5-0","link":"#v2-5-0","children":[]},{"level":3,"title":"v2.4.0","slug":"v2-4-0","link":"#v2-4-0","children":[]},{"level":3,"title":"v2.3.0","slug":"v2-3-0","link":"#v2-3-0","children":[]},{"level":3,"title":"v2.2.0","slug":"v2-2-0","link":"#v2-2-0","children":[]},{"level":3,"title":"v2.1.0","slug":"v2-1-0","link":"#v2-1-0","children":[]},{"level":3,"title":"v2.0.0","slug":"v2-0-0","link":"#v2-0-0","children":[]}]}],"git":{},"filePathRelative":"docs/getting-started/what's-new.md"}`);export{l as data}; diff --git a/preview/assets/index.html-e44840e5.js b/preview/assets/index.html-e44840e5.js new file mode 100644 index 000000000..859fbef44 --- /dev/null +++ b/preview/assets/index.html-e44840e5.js @@ -0,0 +1,115 @@ +import{_ as o,r as i,o as r,c as p,a as n,b as e,d as s,t as c,u,e as t,f as d}from"./app-73097456.js";const m=t('

    Installation

    NotifyBC can be installed in 3 ways:

    1. Deploy locally from Source Code
    2. Deploy to Kubernetes
    3. Deploy Docker Container

    For the purpose of evaluation, both source code and docker container will do. For production, the recommendation is one of

    • deploying to Kubernetes
    • setting up a load balanced app cluster from source code build, backed by MongoDB.

    To setup a development environment in order to contribute to NotifyBC, installing from source code is preferred.

    Deploy locally from Source Code

    System Requirements

    ',8),v=n("li",null,"Git",-1),h={href:"https://nodejs.org",target:"_blank",rel:"noopener noreferrer"},b=n("li",null,"openssl (if enable HTTPS)",-1),k=n("li",null,"MongoDB with replica set, required for production",-1),g=n("li",null,"A standard SMTP server to deliver outgoing email, required for production if email is enabled.",-1),f={href:"http://nginx.org/en/docs/stream/ngx_stream_proxy_module.html",target:"_blank",rel:"noopener noreferrer"},y=n("em",null,"NotifyBC",-1),_=n("li",null,[e("A SMS service provider if needs to enable SMS channel. The supported service providers are "),n("ul",null,[n("li",null,"Twilio (default)"),n("li",null,"Swift")])],-1),x=n("li",null,"Redis v6, required if email or sms throttling is enabled",-1),w=n("li",null,"SiteMinder, if needs SiteMinder authentication",-1),C=n("li",null,"An OIDC provider, if needs OIDC authentication",-1),B=t("
  • Network and Permissions
    • Minimum runtime firewall requirements:
      • outbound to your ISP DNS server
      • outbound to any on port 80 and 443 in order to run build scripts and send SMS messages
      • outbound to any on SMTP port 25 if using direct mail; for SMTP relay, outbound to your configured SMTP server and port only
      • inbound to listening port (3000 by default) from other authorized server ips
      • if NotifyBC instance will handle anonymous subscription from client browser, the listening port should be open to internet either directly or indirectly through a reverse proxy; If NotifyBC instance will only handle SiteMinder authenticated webapp requests, the listening port should NOT be open to internet. Instead, it should only open to SiteMinder web agent reverse proxy.
    • If list-unsubscribe by email is needed, then one of the following must be met
      • NotifyBC can bind to port 25 opening to internet
      • a tcp proxy server of which port 25 is open to internet. This proxy server can reach NotifyBC on a tcp port.
  • ",1),S=t(`

    Installation

    Run following commands

    git clone https://github.com/bcgov/NotifyBC.git
    +cd NotifyBC
    +npm i && npm run build
    +npm run start
    +

    If successful, you will see following output

    ...
    +Server is running at http://0.0.0.0:3000
    +
    `,5),T={href:"http://localhost:3000",target:"_blank",rel:"noopener noreferrer"},D=t(`

    The above commands installs the main version, i.e. main branch tip of NotifyBC GitHub repository. To install a specific version, say v2.1.0, run

     git checkout tags/v2.1.0 -b v2.1.0
    +
    `,2),N=n("code",null,"cd NotifyBC",-1),M={href:"https://github.com/bcgov/NotifyBC/tags",target:"_blank",rel:"noopener noreferrer"},I=t(`

    install from behind firewall

    If you want to install on a server behind firewall which restricts internet connection, you can work around the firewall as long as you have access to a http(s) forward proxy server. Assuming the proxy server is http://my_proxy:8080 which proxies both http and https requests, to use it:

    • For Linux

      export http_proxy=http://my_proxy:8080
      +export https_proxy=http://my_proxy:8080
      +git config --global url."https://".insteadOf git://
      +
    • For Windows

      git config --global http.proxy http://my_proxy:8080
      +git config --global url."https://".insteadOf git://
      +npm config set proxy http://my_proxy:8080
      +

    Install Windows Service

    After get the app running interactively, if your server is Windows and you want to install the app as a Windows service, run

    npm install -g node-windows
    +npm link node-windows
    +node windows-service.js
    +
    `,4),A=n("em",null,"notifyBC",-1),E=n("em",null,"windows-service.js",-1),z={href:"https://github.com/coreybutler/node-windows",target:"_blank",rel:"noopener noreferrer"},O=n("h2",{id:"deploy-to-kubernetes",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#deploy-to-kubernetes","aria-hidden":"true"},"#"),e(" Deploy to Kubernetes")],-1),R=n("em",null,"NotifyBC",-1),P={href:"https://github.com/orgs/bcgov/packages/container/package/notify-bc",target:"_blank",rel:"noopener noreferrer"},q={href:"https://helm.sh/",target:"_blank",rel:"noopener noreferrer"},V={href:"https://docs.microsoft.com/en-us/azure/aks/ingress-basic#create-an-ingress-controller",target:"_blank",rel:"noopener noreferrer"},K=n("p",null,"The deployment can be initiated from localhost or automated by CI service such as Jenkins. Regardless, at the initiator's side following software needs to be installed:",-1),F=n("li",null,"git",-1),H={href:"https://docs.microsoft.com/en-us/cli/azure/",target:"_blank",rel:"noopener noreferrer"},G={href:"https://docs.openshift.org/latest/cli_reference/index.html",target:"_blank",rel:"noopener noreferrer"},j={href:"https://helm.sh/docs/intro/install/",target:"_blank",rel:"noopener noreferrer"},L=t(`

    To install,

    1. Follow your platform's instruction to login to the platform. For AKS, run az login and az aks get-credentials; for OpenShift, run oc login

    2. Run

      git clone https://github.com/bcgov/NotifyBC.git
      +cd NotifyBC
      +helm install -gf helm/platform-specific/<platform>.yaml helm
      +

      replace <platform> with openshift or aks depending on your platform.

      The above commands create following artifacts:

      • 1 stateful set of 3 pods running a MongoDB replicaset
      • 1 stateful set of 3 pods running a Redis sentinel
      • 2 deployments - notify-bc-app and notify-bc-cron
      • 1 HPA - notify-bc-cron
      • 5 services - notify-bc, notify-bc-smtp, mongodb-headless, redis and redis-headless
      • 3 PVCs each for one MongoDB pod
      • 3 service accounts - notify-bc, mongodb and redis
      • a few config maps, most importantly notify-bc
      • a few secrets, most importantly mongodb and redis, containing credentials for Mongodb and Redis respectively
      • On AKS,
        • a notify-bc ingress
      • On OpenShift,
        • 2 routes - notify-bc-web and notify-bc-smtp

    To upgrade,

    helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml --set mongodb.auth.rootPassword=<mongodb-root-password> --set mongodb.auth.replicaSetKey=<mongodb-replica-set-key> --set mongodb.auth.password=<mongodb-password> helm
    +

    replace <release-name> with installed helm release name and <platform> with openshift or aks depending on your platform. MongoDB credentials <mongodb-root-password>, <mongodb-replica-set-key> and <mongodb-password> can be found in secret <release-name>-mongodb. It is recommended to specify mongodb credentials in a file rather than command line. See Customizations below.

    To uninstall,

    helm uninstall <release-name>
    +

    replace <release-name> with installed helm release name.

    Customizations

    `,9),J=n("em",null,".local.yaml",-1),U=n("em",null,"helm/values.local.yaml",-1),W=n("em",null,"helm/values.yaml",-1),Y={href:"https://github.com/bitnami/charts/tree/master/bitnami/mongodb",target:"_blank",rel:"noopener noreferrer"},$=n("em",null,"helm/values.local.yaml",-1),X=n("em",null,"helm/values.yaml",-1),Z=n("em",null,"mongodb",-1),Q=n("em",null,"helm/values.local.yaml",-1),nn=t(`

    To apply customizations, add -f helm/values.local.yaml to the helm command after -f helm/platform-specific/<platform>.yaml. For example, to install chart with customization on OpenShift,

    helm install -gf helm/platform-specific/openshift.yaml -f helm/values.local.yaml helm
    +

    to upgrade an existing release with customization on OpenShift,

    helm upgrade <release-name> -f helm/platform-specific/openshift.yaml -f helm/values.local.yaml helm
    +

    Backup helm/values.local.yaml

    Backup helm/values.local.yaml to a private secured SCM is highly recommended, especially for production environment.

    Following are some common customizations

    `,6),en=t(`
  • Update config.local.js in ConfigMap, for example to define httpHost

    # in file helm/values.local.yaml
    +configMap:
    +  config.local.js: |-
    +    module.exports = {
    +      httpHost: 'https://myNotifyBC.myOrg.com',
    +    }
    +
  • Set hostname on AKS,

    # in file helm/values.local.yaml
    +ingress:
    +  hosts:
    +    - host: myNotifyBC.myOrg.com
    +      paths:
    +        - path: /
    +
  • `,2),an={href:"https://docs.microsoft.com/en-us/azure/aks/ingress-tls",target:"_blank",rel:"noopener noreferrer"},sn=n("em",null,"helm/values.local.yaml",-1),tn=t(`
    # in file helm/values.local.yaml
    +ingress:
    +  annotations:
    +    cert-manager.io/cluster-issuer: letsencrypt
    +  tls:
    +    - secretName: tls-secret
    +      hosts:
    +        - notify-bc.local
    +
    `,1),ln=t(`
  • Route host names on Openshift are by default auto-generated. To set to fixed values

    # in file helm/values.local.yaml
    +route:
    +  web:
    +    host: 'myNotifyBC.myOrg.com'
    +  smtp:
    +    host: 'smtp.myNotifyBC.myOrg.com'
    +
  • Add certificates to OpenShift web route

    # in file helm/values.local.yaml
    +route:
    +  web:
    +    tls:
    +      caCertificate: |-
    +        -----BEGIN CERTIFICATE-----
    +        ...
    +        -----END CERTIFICATE-----
    +      certificate: |-
    +        -----BEGIN CERTIFICATE-----
    +        ...
    +        -----END CERTIFICATE-----
    +      insecureEdgeTerminationPolicy: Redirect
    +      key: |-
    +        -----BEGIN PRIVATE KEY-----
    +        ...
    +        -----END PRIVATE KEY-----
    +
  • `,2),on=n("p",null,"MongoDb",-1),rn=n("em",null,"NotifyBC",-1),pn={href:"https://github.com/bitnami/charts/tree/master/bitnami/mongodb",target:"_blank",rel:"noopener noreferrer"},cn=n("em",null,"mongodb",-1),un=n("em",null,"architecture",-1),dn=n("em",null,"standalone",-1),mn=t(`
    # in file helm/values.local.yaml
    +mongodb:
    +  architecture: standalone
    +

    To set credentials,

    # in file helm/values.local.yaml
    +mongodb:
    +  auth:
    +    rootPassword: <secret>
    +    replicaSetKey: <secret>
    +    passwords:
    +      - <secret>
    +

    To install a Helm chart, the above credentials can be randomly defined. To upgrade an existing release, they must match what's defined in secret <release-name>-mongodb.

    `,4),vn=n("p",null,"Redis",-1),hn=n("em",null,"NotifyBC",-1),bn={href:"https://github.com/bitnami/charts/tree/master/bitnami/redis",target:"_blank",rel:"noopener noreferrer"},kn=n("em",null,"redis",-1),gn=t(`
    # in file helm/values.local.yaml
    +redis:
    +  auth:
    +    password: <secret>
    +

    To install a Helm chart, the above credential can be randomly defined. To upgrade an existing release, It must match what's defined in secret <release-name>-redis.

    `,2),fn=n("li",null,[n("p",null,"Both Bitnami MongoDB and Redis use Docker Hub for docker registry. Rate limit imposed by Docker Hub can cause runtime problems. If your organization has JFrog artifactory, you can change the registry")],-1),yn=t(`
    # in file helm/values.local.yaml
    +global:
    +  imageRegistry: <artifactory.myOrg.com>
    +  imagePullSecrets:
    +    - <docker-pull-secret>
    +
    `,1),_n={href:"https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#create-a-secret-by-providing-credentials-on-the-command-line",target:"_blank",rel:"noopener noreferrer"},xn=t(`
    • Enable scheduled MongoDB backup CronJob

      # in file helm/values.local.yaml
      +cronJob:
      +  enabled: true
      +  schedule: '1 0 * * *'
      +  retentionDays: 7
      +  timeZone: UTC
      +  persistence:
      +    size: 5Gi
      +

      where

      • enabled: whether to enable the MongoDB backup CronJob or not; default to false
      • schedule: the Unix crontab schedule; default to '1 0 * * *' which runs daily at 12:01AM
      • retentionDays: how many days the backup is retained; default to 7
      • timeZone: the Unix TZ environment variable; default to UTC
      • persistence size: size of PVC; default to 5Gi

      The CronJob backs up MongoDB to a PVC named after the chart with suffix -cronjob-mongodb-backup and purges backups that are older than retentionDays.

      To facilitate restoration, mount the PVC to MongoDB pod

      # in file helm/values.local.yaml
      +mongodb:
      +  extraVolumes:
      +    - name: export
      +      persistentVolumeClaim:
      +        claimName: <PVC_NAME>
      +  extraVolumeMounts:
      +    - name: export
      +      mountPath: /export
      +      readOnly: true
      +

      Restoration can then be achieved by running in MongoDB pod

      mongorestore -u "$MONGODB_EXTRA_USERNAMES" -p"$MONGODB_EXTRA_PASSWORDS" \\
      +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_EXTRA_DATABASES --gzip --drop \\
      +--archive=/export/<mongodb-backup-YYMMDD-hhmmss.gz>
      +
    • NotifyBC image tag defaults to appVersion in file helm/Chart.yaml. To change to latest, i.e. tip of the main branch,

      # in file helm/values.local.yaml
      +image:
      +  tag: latest
      +
    • Enable autoscaling for app pod

      # in file helm/values.local.yaml
      +autoscaling:
      +  enabled: true
      +

    Deploy Docker Container

    If you have git and Docker installed, you can run following command to deploy NotifyBC Docker container:

    docker run --platform linux/amd64 --rm -dp 3000:3000 ghcr.io/bcgov/notify-bc
    +# open http://localhost:3000
    +

    If successful, similar output is displayed as in source code installation.

    `,5),wn={__name:"index.html",setup(Cn){const l=d();return(Bn,Sn)=>{const a=i("ExternalLinkIcon");return r(),p("div",null,[m,n("ul",null,[n("li",null,[e("Software "),n("ul",null,[v,n("li",null,[n("a",h,[e("Node.js"),s(a)]),e("@"+c(u(l).packageJson.engines.node),1)]),b])]),n("li",null,[e("Services "),n("ul",null,[k,g,n("li",null,[e("A tcp proxy server such as "),n("a",f,[e("nginx stream proxy"),s(a)]),e(" if list-unsubscribe by email is needed and "),y,e(" server cannot expose port 25 to internet")]),_,x,w,C])]),B]),S,n("p",null,[e("Now open "),n("a",T,[e("http://localhost:3000"),s(a)]),e(". The page displays NotifyBC Web Console.")]),D,n("p",null,[e("after "),N,e(". A list of versions can be found "),n("a",M,[e("here"),s(a)]),e(".")]),I,n("p",null,[e("This will create and start service "),A,e(". To change service name, modify file "),E,e(" before running it. See "),n("a",z,[e("node-windows"),s(a)]),e(" for other operations such as uninstalling the service.")]),O,n("p",null,[R,e(" provides a "),n("a",P,[e("container package"),s(a)]),e(" in GitHub Container Registry and a "),n("a",q,[e("Helm"),s(a)]),e(" chart to facilitate Deploying to Kubernetes. Azure AKS and OpenShift are the two tested platforms. Other Kubernetes platforms are likely to work subject to customizations. Before deploying to AKS, "),n("a",V,[e("create an ingress controller "),s(a)]),e(".")]),K,n("ul",null,[F,n("li",null,[e("Platform-specific CLI such as "),n("a",H,[e("Azure CLI"),s(a)]),e(" or "),n("a",G,[e("OpenShift CLI"),s(a)])]),n("li",null,[n("a",j,[e("Helm CLI"),s(a)])])]),L,n("p",null,[e("Various customizations can be made to chart. Some are platform dependent. To customize, first create a file with extension "),J,e(". The rest of the document assumes the file is "),U,e(". Then add customized parameters to the file. See "),W,e(" and Bitnami MongoDB chart "),n("a",Y,[e("readme"),s(a)]),e(" for customizable parameters. Parameters in "),$,e(" overrides corresponding ones in "),X,e(". In particular, parameters under "),Z,e(" of "),Q,e(" overrides Bitnami MongoDB chart parameters.")]),nn,n("ul",null,[en,n("li",null,[n("p",null,[e("Use "),n("a",an,[e("Let's Encrypt on AKS"),s(a)]),e(". After following the instructions in the link, add following ingress customizations to file "),sn]),tn]),ln,n("li",null,[on,n("p",null,[rn,e(" chart depends on "),n("a",pn,[e("Bitnami MongoDB chart"),s(a)]),e(" for MongoDB database provisioning. All documented parameters are customizable under "),cn,e(". For example, to change "),un,e(" to "),dn]),mn]),n("li",null,[vn,n("p",null,[hn,e(" chart depends on "),n("a",bn,[e("Bitnami Redis chart"),s(a)]),e(" for Redis provisioning. All documented parameters are customizable under "),kn,e(". For example, to set credential")]),gn]),fn]),yn,n("p",null,[e("The above settings assume you have setup secret to access . The secret can be created using "),n("a",_n,[e("kubectl"),s(a)]),e(".")]),xn])}}},Dn=o(wn,[["__file","index.html.vue"]]);export{Dn as default}; diff --git a/preview/assets/index.html-e67b9cd4.js b/preview/assets/index.html-e67b9cd4.js new file mode 100644 index 000000000..f9f51c3ba --- /dev/null +++ b/preview/assets/index.html-e67b9cd4.js @@ -0,0 +1,16 @@ +import{_ as c,r as t,o as l,c as p,a as e,d as s,w as i,b as a,e as r}from"./app-73097456.js";const m={},d=r(`

    TLS Certificates

    NotifyBC supports HTTPS TLS to achieve end-to-end encryption. In addition, both server and client can be authenticated using certificates.

    To enable HTTPS for server authentication only, you need to create two files

    • server/certs/key.pem - a PEM encoded private key
    • server/certs/cert.pem - a PEM encoded X.509 certificate chain

    Use ConfigMaps on Kubernetes

    Create key.pem and cert.pem as items in ConfigMap notify-bc, then mount the items under /home/node/app/server/certs similar to how config.local.js and middleware.local.js are implemented.

    For self-signed certificate, run

    openssl req -x509 -newkey rsa:4096 -keyout server/certs/key.pem -out server/certs/cert.pem -nodes -days 365 -subj "/CN=NotifyBC"
    +

    to generate both files in one shot.

    Caution about self-signed cert

    Self-signed cert is intended to be used in non-production environments only to authenticate server. In such environments to allow NotifyBC connecting to itself, environment variable NODE_TLS_REJECT_UNAUTHORIZED must be set to 0.

    To create a CSR from the private key generated above, run

    openssl req -new -key server/certs/key.pem -out server/certs/csr.pem
    +

    Then bring your CSR to your CA to sign. Replace server/certs/cert.pem with the cert signed by CA. If your CA also supplied intermediate certificate in PEM encoded format, say in a file called intermediate.pem, append all of the content of intermediate.pem to file server/certs/cert.pem.

    Make a copy of self-signed server/certs/cert.pem

    If you want to enable client certificate authentication documented below, make sure to copy self-signed server/certs/cert.pem to server/certs/ca.pem before replacing the file with the cert signed by CA. You need the self-signed server/certs/cert.pem to sign client CSR.

    In case you created server/certs/key.pem and server/certs/cert.pem but don't want to enable HTTPS, create following config in src/config.local.js

    module.exports = {
    +  tls: {
    +    enabled: false,
    +  },
    +};
    +
    `,15),u={class:"custom-container warning"},v=e("p",{class:"custom-container-title"},"Update URL configs after enabling HTTPS",-1),h=e("p",null,"Make sure to update the protocol of following URL configs after enabling HTTPS",-1),b=r(`

    Client certificate authentication

    After enabling HTTPS, you can further configure such that a client request can be authenticated using client certificate. To do so, copy self-signed server/certs/cert.pem to server/certs/ca.pem. You will use your server key to sign client certificate CSR, and advertise server/certs/ca.pem as acceptable CAs during TLS handshake.

    Assuming a client's CSR file is named myClientApp_csr.pem, to sign the CSR

    openssl x509 -req -in myClientApp_csr.pem -CA server/certs/ca.pem -CAkey server/certs/key.pem -out myClientApp_cert.pem -set_serial 01 -days 365
    +

    Then give myClientApp_cert.pem to the client. How a client app supplies the client certificate when making a request to NotifyBC varies by client type. Usually the client first needs to bundle the signed client cert and client key into PKCS#12 format

    openssl pkcs12 -export -clcerts -in myClientApp_cert.pem -inkey myClientApp_key.pem -out myClientApp.p12
    +

    To use myClientApp.p12, for cURL,

    curl --insecure --cert myClientApp.p12 --cert-type p12 https://localhost:3000/api/administrators/whoami
    +

    For browsers, check browser's instruction how to import myClientApp.p12. When browser accessing NotifyBC API endpoints such as https://localhost:3000/api/administrators/whoami, the browser will prompt to choose from a list certificates that are signed by the server certificate.

    In case you created server/certs/ca.pem but don't want to enable client certificate authentication, create following config in src/config.local.js

    module.exports = {
    +  tls: {
    +    clientCertificateEnabled: false,
    +  },
    +};
    +
    `,11),f={class:"custom-container warning"},k=e("p",{class:"custom-container-title"},"TLS termination has to be passthrough",-1),g=e("em",null,"NotifyBC",-1),y={href:"https://github.com/bcgov/NotifyBC/blob/main/helm/values.yaml#L140",target:"_blank",rel:"noopener noreferrer"},_=e("em",null,"edge",-1),C=e("em",null,"passthrough",-1),w=e("div",{class:"custom-container tip"},[e("p",{class:"custom-container-title"},[e("i",null,"NotifyBC"),a(" internal request does not use client certificate")]),e("p",null,[a("Requests sent by a "),e("em",null,"NotifyBC"),a(" node back to the app cluster use admin ip list authentication.")])],-1);function T(x,S){const n=t("RouterLink"),o=t("ExternalLinkIcon");return l(),p("div",null,[d,e("div",u,[v,h,e("ul",null,[e("li",null,[s(n,{to:"/docs/config/httpHost.html"},{default:i(()=>[a("httpHost")]),_:1})]),e("li",null,[s(n,{to:"/docs/config/internalHttpHost.html"},{default:i(()=>[a("internalHttpHost")]),_:1})])])]),b,e("div",f,[k,e("p",null,[a("For client certification authentication to work, TLS termination of all reverse proxies has to be set to passthrough rather than offload and reload. This means, for example, when "),g,a(" is hosted on OpenShift, router "),e("a",y,[a("tls termination"),s(o)]),a(" has to be changed from "),_,a(" to "),C,a(".")])]),w])}const N=c(m,[["render",T],["__file","index.html.vue"]]);export{N as default}; diff --git a/preview/assets/index.html-f8b7a659.js b/preview/assets/index.html-f8b7a659.js new file mode 100644 index 000000000..80b2e5bda --- /dev/null +++ b/preview/assets/index.html-f8b7a659.js @@ -0,0 +1 @@ +const i=JSON.parse('{"key":"v-7ed00a2a","path":"/docs/config-adminIpList/","title":"Admin IP List","lang":"en-US","frontmatter":{"permalink":"/docs/config-adminIpList/"},"headers":[],"git":{},"filePathRelative":"docs/config/adminIpList.md"}');export{i as data}; diff --git a/preview/assets/index.html-f9768eb1.js b/preview/assets/index.html-f9768eb1.js new file mode 100644 index 000000000..afc56c4df --- /dev/null +++ b/preview/assets/index.html-f9768eb1.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-94b7dab4","path":"/docs/config-oidc/","title":"OIDC","lang":"en-US","frontmatter":{"permalink":"/docs/config-oidc/"},"headers":[],"git":{},"filePathRelative":"docs/config/oidc.md"}');export{e as data}; diff --git a/preview/assets/index.html-fae03536.js b/preview/assets/index.html-fae03536.js new file mode 100644 index 000000000..b968cbf65 --- /dev/null +++ b/preview/assets/index.html-fae03536.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-ca3407c4","path":"/docs/acknowledgments/","title":"Acknowledgments","lang":"en-US","frontmatter":{"permalink":"/docs/acknowledgments/"},"headers":[],"git":{},"filePathRelative":"docs/meta/acknowledgments.md"}');export{e as data}; diff --git a/preview/assets/index.html-fd34968b.js b/preview/assets/index.html-fd34968b.js new file mode 100644 index 000000000..518597b15 --- /dev/null +++ b/preview/assets/index.html-fd34968b.js @@ -0,0 +1,83 @@ +import{_ as d,r as l,o as u,c,a as e,b as t,d as s,w as o,e as n}from"./app-73097456.js";const p={},m=e("h1",{id:"subscription",tabindex:"-1"},[e("a",{class:"header-anchor",href:"#subscription","aria-hidden":"true"},"#"),t(" Subscription")],-1),b=e("p",null,[t("The subscription API encapsulates the backend workflow of user subscription and un-subscription of push notification service. Depending on whether a API call comes from user browser as a user request or from an authorized server as an admin request, "),e("em",null,"NotifyBC"),t(" applies different validation rules. For user requests, the notification channel entered by user is unconfirmed. A confirmation code will be associated with this request. The confirmation code can be created in one of two ways:")],-1),h=e("em",null,"NotifyBC",-1),f=e("em",null,"subscription.confirmationRequest..confirmationCodeRegex",-1),v=e("em",null,"NotifyBC",-1),g=e("em",null,"NotifyBC",-1),q=e("em",null,"NotifyBC",-1),y=e("em",null,"NotifyBC",-1),k=e("em",null,"NotifyBC",-1),_=e("p",null,[t("Equipped with the confirmation code and a message template, "),e("em",null,"NotifyBC"),t(" can now send out confirmation request to unconfirmed subscription channel. At a minimum this confirmation request should contain the confirmation code. When user receives the message, he/she echos the confirmation code back to a "),e("em",null,"NotifyBC"),t(" provided API to verify against saved record. If match, the state of the subscription request is changed to confirmed.")],-1),x=e("p",null,[t("For admin requests, "),e("em",null,"NotifyBC"),t(" can still perform the above confirmation process. But admin request has full CRUD privilege, including set the subscription state to confirmed, bypassing the confirmation process.")],-1),w=e("em",null,"NotifyBC",-1),C=["src"],j=e("p",null,[t("In the case user subscribing to notifications offered by different service providers in separate trust domains, the confirmation code is generated by a third-party server app trusted by all "),e("em",null,"NotifyBC"),t(" instances. Following sequence diagram shows the workflow. The diagram indicates "),e("em",null,"NotifyBC API Server 2"),t(" is chosen to send confirmation request.")],-1),I=["src"],N=n(`

    Model Schema

    The API operates on following subscription data model fields:

    NameAttributes

    serviceName

    name of the service. Avoid prefixing the name with underscore (_), or it may conflict with internal implementation.

    typestring
    requiredtrue

    channel

    name of the delivery channel. Valid values: email and sms. Notice inApp is invalid as in-app notification doesn't need subscription.

    typestring
    requiredtrue
    defaultemail

    userChannelId

    user's delivery channel id, for example, email address

    typestring
    requiredtrue

    id

    subscription id

    typestring, format depends on db
    requiredfalse
    auto-generatedtrue

    state

    state of subscription. Valid values: unconfirmed, confirmed, deleted

    typestring
    requiredfalse
    defaultunconfirmed

    userId

    user id. Auto-populated for authenticated user requests.

    typestring
    requiredfalse

    created

    date and time of creation

    typedate
    requiredfalse
    auto-generatedtrue

    updated

    date and time of last update

    typedate
    requiredfalse
    auto-generatedtrue

    confirmationRequest

    an object containing these child fields
    • confirmationCodeRegex
      • type: string
      • regular expression used to generate confirmation code
    • confirmationCodeEncrypted
      • type: string
      • encrypted confirmation code
    • sendRequest
      • type: boolean
      • whether to send confirmation request
    • from, subject, textBody, htmlBody
      • type: string
      • these are email template fields used for sending email confirmation request. If confirmationRequest.sendRequest is true and channel is email, then these fields should be supplied in order to send confirmation email.
    typeobject
    requiredtrue for user request with encrypted confirmation code; false otherwise

    broadcastPushNotificationFilter

    a string conforming to jmespath filter expressions syntax after the question mark (?). The filter is matched against the data field of the subscription. Examples of filter
    • simple
      province == 'BC'
    • calling jmespath's built-in functions
      contains(province,'B')
    • calling custom filter functions
      contains_ci(province,'b')
    • compound
      (contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria'
    All of above filters will match data object {"province": "BC", "city": "Victoria"}
    typestring
    requiredfalse

    data

    An object used by

    data object can only be populated by non-anonymous requests.

    typeobject
    requiredfalse

    unsubscriptionCode

    generated randomly according to RegEx config anonymousUnsubscription.code.regex during anonymous subscription if config anonymousUnsubscription.code.required is set to true

    typestring
    requiredfalse
    auto-generatedtrue

    unsubscribedAdditionalServices

    generated if parameter additionalServices is supplied in unsubscription request. Contains 2 sub-fields: ids and names, each being a list identifying the additional unsubscribed subscriptions.

    typeobject
    requiredfalse
    auto-generatedtrue

    Get Subscriptions

    GET /subscriptions
    +
    `,5),T=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin"),e("li",null,"authenticated user")])],-1),A=e("p",null,"inputs",-1),B=n("

    a filter containing properties where, fields, order, skip, and limit

    • parameter name: filter
    • required: false
    • parameter type: query
    • data type: object

    The filter can be expressed as either

    ",3),R=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),S={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},E=e("code",null,'?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"',-1),P=n(`

    Regardless, the filter will have to be parsed into a JSON object conforming to

    {
    +    "where": {...},
    +    "fields": ...,
    +    "order": ...,
    +    "skip": ...,
    +    "limit": ...,
    +}
    +

    All properties are optional. The syntax for each property is documented, respectively

    `,3),D=e("em",null,"where",-1),U={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},G=e("em",null,"fields",-1),V={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.select()",target:"_blank",rel:"noopener noreferrer"},$=e("em",null,"order",-1),F={href:"https://mongoosejs.com/docs/api/query.html#Query.prototype.sort()",target:"_blank",rel:"noopener noreferrer"},L=e("em",null,"skip",-1),M={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.skip/",target:"_blank",rel:"noopener noreferrer"},O=e("em",null,"limit",-1),J={href:"https://www.mongodb.com/docs/manual/reference/method/cursor.limit/",target:"_blank",rel:"noopener noreferrer"},Q=n(`
  • outcome

    • for admin requests, returns unabridged array of subscription data matching the filter
    • for authenticated user requests, in addition to filter, following constraints are imposed on the returned array
      • only non-deleted subscriptions
      • only subscriptions created by the user
      • the confirmationRequest field is removed.
    • forbidden for anonymous user requests
  • example

    to retrieve subscriptions created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/subscriptions?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
    +

    the value of the filter query parameter is URL-encoded stringified JSON object

    {
    +  "where": {
    +    "created": {
    +      "$gte": "2023-01-01",
    +      "$lt": "2024-01-01"
    +    }
    +  }
    +}
    +
  • `,2),Z=n(`

    Get Subscription Count

    GET /subscriptions/count
    +
    `,2),W=e("li",null,[e("p",null,"permissions required, one of"),e("ul",null,[e("li",null,"super admin"),e("li",null,"admin"),e("li",null,"authenticated user")])],-1),K=e("p",null,"inputs",-1),X=e("em",null,"where",-1),z={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},H=e("ul",null,[e("li",null,"parameter name: where"),e("li",null,"required: false"),e("li",null,"parameter type: query"),e("li",null,"data type: object")],-1),Y=e("p",null,"The value can be expressed as either",-1),ee=e("li",null,"URL-encoded stringified JSON object (see example below); or",-1),te={href:"https://github.com/ljharb/qs",target:"_blank",rel:"noopener noreferrer"},ne=e("code",null,'?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"',-1),se=n(`
  • outcome

    Validations rules are the same as GET /subscriptions. If passed, the output is a count of subscriptions matching the query

    {
    +  "count": <number>
    +}
    +
  • example

    to retrieve the count of subscriptions created in year 2023, run

    curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/subscriptions/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
    +

    the value of the where query parameter is URL-encoded stringified JSON object

    {
    +  "created": {
    +    "$gte": "2023-01-01",
    +    "$lt": "2024-01-01"
    +  }
    +}
    +
  • `,2),ie=n(`

    Create a Subscription

    POST /subscriptions
    +
    `,2),ae=e("li",null,[e("p",null,"inputs"),e("ul",null,[e("li",null,[t("an object containing subscription data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated. "),e("ul",null,[e("li",null,"parameter name: data"),e("li",null,"required: true"),e("li",null,"parameter type: body"),e("li",null,"data type: object")])])])],-1),oe=e("p",null,"outcome",-1),re=e("p",null,[e("em",null,"NotifyBC"),t(" performs following actions in sequence")],-1),le=n("
  • inputs are validated. If validation fails, error is returned.

  • for user requests, the state field is forced to unconfirmed

  • for authenticated user request, userId field is populated with authenticated userId

  • otherwise, unsubscriptionCode is generated if config subscription.anonymousUnsubscription.code.required is true, unless if the request is made by admin and the field is already populated

  • if confirmationRequest.confirmationCodeEncrypted is populated, a confirmation code is generated by decrypting this field using private RSA key, then put decrypted confirmation code to field confirmationRequest.confirmationCode

  • otherwise, for user requests and for admin requests missing message template, the message template is set to configured value. Then, if confirmationRequest.confirmationCodeRegex is populated, a confirmation code is generated conforming to regex and put to field confirmationRequest.confirmationCode

  • the subscription request is saved to database.

  • ",7),de=n("

    if confirmationRequest.sendRequest is true, then a message is sent to userChannelId. The message template is determined by

    1. if detectDuplicatedSubscription is true and there is already a confirmed subscription to the same serviceName, channel and userChannelId, then message is sent using duplicatedSubscriptionNotification as template;
    2. otherwise, a confirmation request is sent to using the template fields in confirmationRequest.
    ",2),ue=e("li",null,[e("p",null,[t("The subscription data, including auto-generated id, is returned as response unless there is error when sending confirmation request or saving to database. For user request, some fields containing sensitive information such as "),e("em",null,"confirmationRequest"),t(" are removed prior to sending the response.")])],-1),ce=n(`
  • examples

    1. To subscribe a user to service education, copy and paste following json object to the data value box in API explorer, change email addresses as needed, and click Try it out! button:
    {
    +  "serviceName": "education",
    +  "channel": "email",
    +  "userChannelId": "foo@bar.com"
    +}
    +

    As a result, foo@bar.com should receive an email confirmation request, and following json object is returned to caller upon sending the email successfully for admin request:

    {
    +  "serviceName": "education",
    +  "channel": "email",
    +  "userChannelId": "foo@bar.com",
    +  "state": "unconfirmed",
    +  "confirmationRequest": {
    +    "confirmationCodeRegex": "\\\\d{5}",
    +    "sendRequest": true,
    +    "from": "no_reply@bar.com",
    +    "subject": "confirmation",
    +    "textBody": "Enter {confirmation_code} on screen",
    +    "confirmationCode": "45304"
    +  },
    +  "created": "2016-10-03T17:35:40.202Z",
    +  "updated": "2016-10-03T17:35:40.202Z",
    +  "id": "57f296ec7eead50554c61de7"
    +}
    +

    For non-admin request, the field confirmationRequest is removed from response, and field userId is populated if request is authenticated:

    {
    +  "serviceName": "education",
    +  "channel": "email",
    +  "userChannelId": "foo@bar.com",
    +  "state": "unconfirmed",
    +  "userId": "<user_id>",
    +  "created": "2016-10-03T18:17:09.778Z",
    +  "updated": "2016-10-03T18:17:09.778Z",
    +  "id": "57f2a0a5b1aa0e2d5009eced"
    +}
    +
    1. To subscribe a user to service education with RSA public key encrypted confirmation code supplied, POST following request

      {
      +  "serviceName": "education",
      +  "channel": "email",
      +  "userChannelId": "foo@bar.com",
      +  "confirmationRequest": {
      +    "confirmationCodeEncrypted": "<encrypted-confirmation-code>",
      +    "sendRequest": true,
      +    "from": "no_reply@bar.com",
      +    "subject": "confirmation",
      +    "textBody": "Enter {confirmation_code} on screen"
      +  }
      +}
      +

      As a result, NotifyBC will decrypt the confirmation code using the private RSA key, replace placeholder {confirmation_code} in the email template with the confirmation code, and send confirmation request to foo@bar.com.

  • `,1),pe=n(`

    Verify a Subscription

    GET /subscriptions/{id}/verify
    +
    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • confirmation code
        • parameter name: confirmationCode
        • required: true
        • parameter type: query
        • data type: string
      • whether or not replacing existing subscriptions
        • parameter name: replace
        • required: false
        • parameter type: query
        • data type: boolean
    • outcome

      NotifyBC performs following actions in sequence

      1. the subscription identified by id is retrieved
      2. for user request, the userId of the subscription is checked against current request user, if not match, error is returned; otherwise
      3. input parameter confirmationCode is checked against confirmationRequest.confirmationCode. If not match, error is returned; otherwise
      4. if input parameter replace is supplied and set to true, then existing confirmed subscriptions from the same serviceName, channel and userChannelId are deleted. No unsubscription acknowledgement notification is sent
      5. state is set to confirmed
      6. the subscription is saved back to database
      7. displays acknowledgement message according to configuration
    • example

      to verify a subscription with id abc, confirmation code 12345, and delete existing confirmed subscriptions once verified, run

      curl 'http://localhost:3000/api/subscriptions/abc/verify?confirmationCode=12345&replace=true'
      +

    Update a Subscription

    PATCH /subscriptions/{id}
    +

    This API is used by authenticated user to change user channel id (such as email address) and resend confirmation code.

    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC processes the request similarly as creating a subscription except during input validation it imposes following extra constraints to user request

      • only fields userChannelId, state and confirmationRequest can be updated
      • when changing userChannelId, confirmationRequest must also be supplied
      • if userChannelId is different from the saved record, state is forced to unconfirmed.

    Delete a Subscription (unsubscribing)

    DELETE /subscriptions/{id}?unsubscriptionCode={unsubscriptionCode}&additionalServices[]={additionalServices}&userChannelId={userChannelId}
    +or
    +GET /subscriptions/{id}/unsubscribe?unsubscriptionCode={unsubscriptionCode}&additionalServices[]={additionalServices}&userChannelId={userChannelId}
    +
    `,9),me=n('
  • inputs

    • subscription id
      • parameter name: id
      • required: true
      • parameter type: path
      • data type: string
    • unsubscription code for anonymous request
      • parameter name: unsubscriptionCode
      • required: false
      • parameter type: query
      • data type: string
    • additional service names to unsubscribe
      • parameter name: additionalServices
      • required: false
      • parameter type: query
      • data type: array of strings. If there is only one item and its value is _all, then all services the user subscribed on this NotifyBC instance are included. Supply multiple items by repeating this query parameter.
    • user channel id for extended validation
      • parameter name: userChannelId
      • required: false
      • parameter type: query
      • data type: string
  • outcome

    NotifyBC performs following actions in sequence

    1. the subscription identified by id is retrieved
    2. for user request,
    • if request is authenticated, the userId of the subscription is checked against current request user, if not match, request is rejected
    • if request is anonymous, and server is configured to require unsubscription code, the input unsubscription code is matched against the unsubscriptionCode field. Request is rejected if not match. In addition, if input parameter userChannelId is populated but doesn't match, request is rejected
    1. if the subscription state is not confirmed, request is rejected
    2. if additionalServices is populated, database is queried to retrieve the serviceName and id fields of the additional subscriptions
    3. the field state is set to deleted for the subscription identified by id as well as additional subscriptions retrieved in previous step
    4. if additionalServices is not empty, the service names and ids of the additional subscriptions are added to field unsubscribedAdditionalServices of the subscription identified by id to allow bulk undo unsubscription later on
    5. for anonymous unsubscription, an acknowledgement notification is sent to user if configured so
    6. returns
    • for anonymous request, either the message or redirect as configured in anonymousUnsubscription.acknowledgements.onScreen
    • for authenticated user or admin requests, number of records affected or error message if occurred.
  • ',2),be=e("p",null,[e("a",{name:"unsubscription-example"}),t(" examples")],-1),he=e("em",null,"{unsubscription_url}",-1),fe=e("li",null,[t("To allow an anonymous subscriber to unsubscribe all subscriptions, provide url token "),e("em",null,"{unsubscription_all_url}"),t(" in notification messages.")],-1),ve=n(`

    Un-deleting a Subscription

    GET /subscriptions/{id}/unsubscribe/undo
    +

    This API allows an anonymous subscriber to undo an unsubscription.

    `,3),ge=n("
  • inputs

    • subscription id
      • parameter name: id
      • required: true
      • parameter type: path
      • data type: string
    • unsubscription code
      • parameter name: unsubscriptionCode
      • required: false
      • parameter type: query
      • data type: string
  • ",1),qe=n("

    outcome

    NotifyBC performs following actions in sequence

    1. the subscription identified by id is retrieved
    2. for user request,
    • if request is anonymous, and server is configured to require unsubscription code, the input unsubscription code is matched against the unsubscriptionCode field. Request is rejected if not match
    • if request is authenticated, request is rejected
    • if the subscription state is not deleted, request is rejected
    ",4),ye={start:"3"},ke=e("li",null,[t("the field "),e("em",null,"state"),t(" is set to "),e("em",null,"confirmed"),t(" for the subscription identified by "),e("em",null,"id"),t(" as well as additional subscriptions identified in field "),e("em",null,"unsubscribedAdditionalServices"),t(", if populated")],-1),_e=e("li",null,[t("field "),e("em",null,"unsubscribedAdditionalServices"),t(" is removed if populated")],-1),xe=e("p",null,"example",-1),we=e("em",null,"{unsubscription_reversion_url}",-1),Ce=n(`

    Get all services with confirmed subscribers

    GET /subscriptions/services
    +

    This API is designed to facilitate implementing autocomplete for admin web console.

    • permissions required, one of
      • super admin
      • admin
    • inputs - none
    • outcome
      • for admin requests, returns an array of unique service names with confirmed subscribers
      • forbidden for non-admin requests

    Replace a Subscription

    PUT /subscriptions/{id}
    +

    This API is intended to be only used by admin web console to modify a subscription without triggering any confirmation or acknowledgement notification.

    • permissions required, one of
      • super admin
      • admin
    • permissions required, one of
      • super admin
      • admin
      • authenticated user
    • inputs
      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • subscription data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome
      • for admin requests, replace subscription identified by id with parameter data and save to database. No notification is sent.
      • forbidden for non-admin requests
    `,8);function je(r,Ie){const a=l("RouterLink"),i=l("ExternalLinkIcon");return u(),c("div",null,[m,b,e("ul",null,[e("li",null,[t("by "),h,t(" based on channel dependent "),f,t(),s(a,{to:"/docs/config-subscription/#confirmation-request-message"},{default:o(()=>[t("config")]),_:1}),t(".")]),e("li",null,[t("by a trusted third party. This trusted third party encrypts the confirmation code using the public RSA key of the "),v,t(" instance (see more about "),s(a,{to:"/docs/config-rsaKeys/"},{default:o(()=>[t("RSA Key Config")]),_:1}),t(") and pass the encrypted confirmation code to "),g,t(" via user browser in the same subscription request. "),q,t(" then decrypts to obtain the confirmation code. This method allows user subscribe to multiple notification services provided by "),y,t(" instances in different trust domains (i.e. service providers) and only have to confirm the subscription channel once during one browser session. In such case only one "),k,t(" instance should be chosen to deliver confirmation request to user.")])]),_,x,e("p",null,[t("The workflow of user subscribing to notification services offered by a single service provider is illustrated by sequence diagram below. In this case, the confirmation code is generated by "),w,t(". "),e("img",{src:r.$withBase("/img/subscription-single-service-provider.png"),alt:"single service provider subscription"},null,8,C)]),j,e("img",{src:r.$withBase("/img/subscription-multi-service-provider.png"),alt:"multi service provider subscription"},null,8,I),N,e("ul",null,[T,e("li",null,[A,e("ul",null,[e("li",null,[B,e("ol",null,[R,e("li",null,[t("in the format supported by "),e("a",S,[t("qs"),s(i)]),t(", for example "),E])]),P,e("ul",null,[e("li",null,[t("for "),D,t(" , see MongoDB "),e("a",U,[t("Query Documents"),s(i)])]),e("li",null,[t("for "),G,t(" , see Mongoose "),e("a",V,[t("select"),s(i)])]),e("li",null,[t("for "),$,t(", see Mongoose "),e("a",F,[t("sort"),s(i)])]),e("li",null,[t("for "),L,t(", see MongoDB "),e("a",M,[t("cursor.skip"),s(i)])]),e("li",null,[t("for "),O,t(", see MongoDB "),e("a",J,[t("cursor.limit"),s(i)])])])])])]),Q]),Z,e("ul",null,[W,e("li",null,[K,e("ul",null,[e("li",null,[e("p",null,[t("a "),X,t(" query parameter with value conforming to MongoDB "),e("a",z,[t("Query Documents"),s(i)])]),H,Y,e("ol",null,[ee,e("li",null,[t("in the format supported by "),e("a",te,[t("qs"),s(i)]),t(", for example "),ne])])])])]),se]),ie,e("ul",null,[ae,e("li",null,[oe,re,e("ol",null,[le,e("li",null,[de,e("p",null,[s(a,{to:"/docs/overview/#mail-merge"},{default:o(()=>[t("Mail merge")]),_:1}),t(" is performed on the template regardless.")])]),ue])]),ce]),pe,e("ul",null,[me,e("li",null,[be,e("ol",null,[e("li",null,[t("To allow an anonymous subscriber to unsubscribe single subscription, provide url token "),he,t(" in notification messages. When sending notification, "),s(a,{to:"/docs/overview/#mail-merge"},{default:o(()=>[t("mail merge")]),_:1}),t(" is performed on the token resolving to the GET API url and parameters.")]),fe])])]),ve,e("ul",null,[ge,e("li",null,[qe,e("ol",ye,[ke,_e,e("li",null,[t("returns either the message or redirect as configured in "),e("em",null,[s(a,{to:"/docs/config-subscription/#anonymousUndoUnsubscription"},{default:o(()=>[t("anonymousUndoUnsubscription")]),_:1})])])])]),e("li",null,[xe,e("p",null,[t("To allow an anonymous subscriber to undo unsubscription, provide link token "),we,t(" in unsubscription acknowledgement notification, which is by default set. When sending notification, "),s(a,{to:"/docs/overview/#mail-merge"},{default:o(()=>[t("mail merge")]),_:1}),t(" is performed on this token resolving to the API url and parameters.")])])]),Ce])}const Te=d(p,[["render",je],["__file","index.html.vue"]]);export{Te as default}; diff --git a/preview/assets/jmespathFilter.html-35e45519.js b/preview/assets/jmespathFilter.html-35e45519.js new file mode 100644 index 000000000..72661a035 --- /dev/null +++ b/preview/assets/jmespathFilter.html-35e45519.js @@ -0,0 +1,18 @@ +import{_ as t,o as n,c as e,a as i}from"./app-73097456.js";const o={},a=i("pre",null,[i("code",null,`
    a string conforming to jmespath filter expressions syntax after the question mark (?). The filter is matched against the data field of the subscription. Examples of filter +
      +
    • simple
      + province == 'BC' +
    • +
    • calling jmespath's built-in functions
      + contains(province,'B') +
    • +
    • calling custom filter functions
      + contains_ci(province,'b') +
    • +
    • compound
      + (contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria' +
    • +
    + All of above filters will match data object {"province": "BC", "city": "Victoria"} +
    +`)],-1),c=[a];function s(r,l){return n(),e("div",null,c)}const p=t(o,[["render",s],["__file","jmespathFilter.html.vue"]]);export{p as default}; diff --git a/preview/assets/jmespathFilter.html-7c48b0c1.js b/preview/assets/jmespathFilter.html-7c48b0c1.js new file mode 100644 index 000000000..970e6715d --- /dev/null +++ b/preview/assets/jmespathFilter.html-7c48b0c1.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-9a1a7988","path":"/docs/shared/jmespathFilter.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/jmespathFilter.md"}');export{e as data}; diff --git a/preview/assets/style-8fbc3cfb.css b/preview/assets/style-8fbc3cfb.css new file mode 100644 index 000000000..1c9bd6c2b --- /dev/null +++ b/preview/assets/style-8fbc3cfb.css @@ -0,0 +1 @@ +:root{--back-to-top-z-index: 5;--back-to-top-color: #3eaf7c;--back-to-top-color-hover: #71cda3}.back-to-top{cursor:pointer;position:fixed;bottom:2rem;right:2.5rem;width:2rem;height:1.2rem;background-color:var(--back-to-top-color);-webkit-mask:url(/NotifyBC/preview/assets/back-to-top-8efcbe56.svg) no-repeat;mask:url(/NotifyBC/preview/assets/back-to-top-8efcbe56.svg) no-repeat;z-index:var(--back-to-top-z-index)}.back-to-top:hover{background-color:var(--back-to-top-color-hover)}@media (max-width: 959px){.back-to-top{display:none}}@media print{.back-to-top{display:none}}.back-to-top-enter-active,.back-to-top-leave-active{transition:opacity .3s}.back-to-top-enter-from,.back-to-top-leave-to{opacity:0}:root{--external-link-icon-color: #aaa}.external-link-icon{position:relative;display:inline-block;color:var(--external-link-icon-color);vertical-align:middle;top:-1px}@media print{.external-link-icon{display:none}}.external-link-icon-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}:root{--medium-zoom-z-index: 100;--medium-zoom-bg-color: #ffffff;--medium-zoom-opacity: 1}.medium-zoom-overlay{background-color:var(--medium-zoom-bg-color)!important;z-index:var(--medium-zoom-z-index)}.medium-zoom-overlay~img{z-index:calc(var(--medium-zoom-z-index) + 1)}.medium-zoom--opened .medium-zoom-overlay{opacity:var(--medium-zoom-opacity)}:root{--nprogress-color: #29d;--nprogress-z-index: 1031}#nprogress{pointer-events:none}#nprogress .bar{background:var(--nprogress-color);position:fixed;z-index:var(--nprogress-z-index);top:0;left:0;width:100%;height:2px}select[data-v-888e697c]{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;border:1px solid}@media only screen and (max-width: 400px){.nb-navbar-brand .logo{width:70px}.nb-navbar-brand span{font-size:.8em}.navbar .navbar-items-wrapper{padding-left:0!important}}:root{--c-brand: #3eaf7c;--c-brand-light: #4abf8a;--c-bg: #ffffff;--c-bg-light: #f3f4f5;--c-bg-lighter: #eeeeee;--c-bg-dark: #ebebec;--c-bg-darker: #e6e6e6;--c-bg-navbar: var(--c-bg);--c-bg-sidebar: var(--c-bg);--c-bg-arrow: #cccccc;--c-text: #2c3e50;--c-text-accent: var(--c-brand);--c-text-light: #3a5169;--c-text-lighter: #4e6e8e;--c-text-lightest: #6a8bad;--c-text-quote: #999999;--c-border: #eaecef;--c-border-dark: #dfe2e5;--c-tip: #42b983;--c-tip-bg: var(--c-bg-light);--c-tip-title: var(--c-text);--c-tip-text: var(--c-text);--c-tip-text-accent: var(--c-text-accent);--c-warning: #ffc310;--c-warning-bg: #fffae3;--c-warning-bg-light: #fff3ba;--c-warning-bg-lighter: #fff0b0;--c-warning-border-dark: #f7dc91;--c-warning-details-bg: #fff5ca;--c-warning-title: #f1b300;--c-warning-text: #746000;--c-warning-text-accent: #edb100;--c-warning-text-light: #c1971c;--c-warning-text-quote: #ccab49;--c-danger: #f11e37;--c-danger-bg: #ffe0e0;--c-danger-bg-light: #ffcfde;--c-danger-bg-lighter: #ffc9c9;--c-danger-border-dark: #f1abab;--c-danger-details-bg: #ffd4d4;--c-danger-title: #ed1e2c;--c-danger-text: #660000;--c-danger-text-accent: #bd1a1a;--c-danger-text-light: #b5474d;--c-danger-text-quote: #c15b5b;--c-details-bg: #eeeeee;--c-badge-tip: var(--c-tip);--c-badge-warning: #ecc808;--c-badge-warning-text: var(--c-bg);--c-badge-danger: #dc2626;--c-badge-danger-text: var(--c-bg);--t-color: .3s ease;--t-transform: .3s ease;--code-bg-color: #282c34;--code-hl-bg-color: rgba(0, 0, 0, .66);--code-ln-color: #9e9e9e;--code-ln-wrapper-width: 3.5rem;--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;--font-family-code: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;--navbar-height: 3.6rem;--navbar-padding-v: .7rem;--navbar-padding-h: 1.5rem;--sidebar-width: 20rem;--sidebar-width-mobile: calc(var(--sidebar-width) * .82);--content-width: 740px;--homepage-width: 960px}.back-to-top{--back-to-top-color: var(--c-brand);--back-to-top-color-hover: var(--c-brand-light)}.DocSearch{--docsearch-primary-color: var(--c-brand);--docsearch-text-color: var(--c-text);--docsearch-highlight-color: var(--c-brand);--docsearch-muted-color: var(--c-text-quote);--docsearch-container-background: rgba(9, 10, 17, .8);--docsearch-modal-background: var(--c-bg-light);--docsearch-searchbox-background: var(--c-bg-lighter);--docsearch-searchbox-focus-background: var(--c-bg);--docsearch-searchbox-shadow: inset 0 0 0 2px var(--c-brand);--docsearch-hit-color: var(--c-text-light);--docsearch-hit-active-color: var(--c-bg);--docsearch-hit-background: var(--c-bg);--docsearch-hit-shadow: 0 1px 3px 0 var(--c-border-dark);--docsearch-footer-background: var(--c-bg)}.external-link-icon{--external-link-icon-color: var(--c-text-quote)}.medium-zoom-overlay{--medium-zoom-bg-color: var(--c-bg)}#nprogress{--nprogress-color: var(--c-brand)}.pwa-popup{--pwa-popup-text-color: var(--c-text);--pwa-popup-bg-color: var(--c-bg);--pwa-popup-border-color: var(--c-brand);--pwa-popup-shadow: 0 4px 16px var(--c-brand);--pwa-popup-btn-text-color: var(--c-bg);--pwa-popup-btn-bg-color: var(--c-brand);--pwa-popup-btn-hover-bg-color: var(--c-brand-light)}.search-box{--search-bg-color: var(--c-bg);--search-accent-color: var(--c-brand);--search-text-color: var(--c-text);--search-border-color: var(--c-border);--search-item-text-color: var(--c-text-lighter);--search-item-focus-bg-color: var(--c-bg-light)}html.dark{--c-brand: #3aa675;--c-brand-light: #349469;--c-bg: #22272e;--c-bg-light: #2b313a;--c-bg-lighter: #262c34;--c-bg-dark: #343b44;--c-bg-darker: #37404c;--c-text: #adbac7;--c-text-light: #96a7b7;--c-text-lighter: #8b9eb0;--c-text-lightest: #8094a8;--c-border: #3e4c5a;--c-border-dark: #34404c;--c-tip: #318a62;--c-warning: #e0ad15;--c-warning-bg: #2d2f2d;--c-warning-bg-light: #423e2a;--c-warning-bg-lighter: #44442f;--c-warning-border-dark: #957c35;--c-warning-details-bg: #39392d;--c-warning-title: #fdca31;--c-warning-text: #d8d96d;--c-warning-text-accent: #ffbf00;--c-warning-text-light: #ddb84b;--c-warning-text-quote: #ccab49;--c-danger: #fc1e38;--c-danger-bg: #39232c;--c-danger-bg-light: #4b2b35;--c-danger-bg-lighter: #553040;--c-danger-border-dark: #a25151;--c-danger-details-bg: #482936;--c-danger-title: #fc2d3b;--c-danger-text: #ea9ca0;--c-danger-text-accent: #fd3636;--c-danger-text-light: #d9777c;--c-danger-text-quote: #d56b6b;--c-details-bg: #323843;--c-badge-warning: var(--c-warning);--c-badge-warning-text: #3c2e05;--c-badge-danger: var(--c-danger);--c-badge-danger-text: #401416;--code-hl-bg-color: #363b46}html.dark .DocSearch{--docsearch-logo-color: var(--c-text);--docsearch-modal-shadow: inset 1px 1px 0 0 #2c2e40, 0 3px 8px 0 #000309;--docsearch-key-shadow: inset 0 -2px 0 0 #282d55, inset 0 0 1px 1px #51577d, 0 2px 2px 0 rgba(3, 4, 9, .3);--docsearch-key-gradient: linear-gradient(-225deg, #444950, #1c1e21);--docsearch-footer-shadow: inset 0 1px 0 0 rgba(73, 76, 106, .5), 0 -4px 8px 0 rgba(0, 0, 0, .2)}html,body{padding:0;margin:0;background-color:var(--c-bg);transition:background-color var(--t-color)}html.dark{color-scheme:dark}html{font-size:16px}body{font-family:var(--font-family);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-size:1rem;color:var(--c-text)}a{font-weight:500;color:var(--c-text-accent);text-decoration:none;overflow-wrap:break-word}p a code{font-weight:400;color:var(--c-text-accent)}kbd{font-family:var(--font-family-code);color:var(--c-text);background:var(--c-bg-lighter);border:solid .15rem var(--c-border-dark);border-bottom:solid .25rem var(--c-border-dark);border-radius:.15rem;padding:0 .15em}code{font-family:var(--font-family-code);color:var(--c-text-lighter);padding:.25rem .5rem;margin:0;font-size:.85em;background-color:var(--c-bg-light);border-radius:3px;overflow-wrap:break-word;transition:background-color var(--t-color)}blockquote{font-size:1rem;color:var(--c-text-quote);border-left:.2rem solid var(--c-border-dark);margin:1rem 0;padding:.25rem 0 .25rem 1rem;overflow-wrap:break-word}blockquote>p{margin:0}ul,ol{padding-left:1.2em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{font-weight:600;line-height:1.25;overflow-wrap:break-word}h1:focus-visible,h2:focus-visible,h3:focus-visible,h4:focus-visible,h5:focus-visible,h6:focus-visible{outline:none}h1:hover .header-anchor,h2:hover .header-anchor,h3:hover .header-anchor,h4:hover .header-anchor,h5:hover .header-anchor,h6:hover .header-anchor{opacity:1}h1{font-size:2.2rem}h2{font-size:1.65rem;padding-bottom:.3rem;border-bottom:1px solid var(--c-border);transition:border-color var(--t-color)}h3{font-size:1.35rem}h4{font-size:1.15rem}h5{font-size:1.05rem}h6{font-size:1rem}a.header-anchor{font-size:.85em;float:left;margin-left:-.87em;padding-right:.23em;margin-top:.125em;opacity:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media print{a.header-anchor{display:none}}a.header-anchor:hover{text-decoration:none}a.header-anchor:focus-visible{opacity:1}@media print{a[href^="http://"]:after,a[href^="https://"]:after{content:" (" attr(href) ") "}}p,ul,ol{line-height:1.7;overflow-wrap:break-word}hr{border:0;border-top:1px solid var(--c-border)}table{border-collapse:collapse;margin:1rem 0;display:block;overflow-x:auto;transition:border-color var(--t-color)}tr{border-top:1px solid var(--c-border-dark);transition:border-color var(--t-color)}tr:nth-child(2n){background-color:var(--c-bg-light);transition:background-color var(--t-color)}tr:nth-child(2n) code{background-color:var(--c-bg-dark)}th,td{padding:.6em 1em;border:1px solid var(--c-border-dark);transition:border-color var(--t-color)}.arrow{display:inline-block;width:0;height:0}.arrow.up{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid var(--c-bg-arrow)}.arrow.down{border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid var(--c-bg-arrow)}.arrow.right{border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:6px solid var(--c-bg-arrow)}.arrow.left{border-top:4px solid transparent;border-bottom:4px solid transparent;border-right:6px solid var(--c-bg-arrow)}.badge{display:inline-block;font-size:14px;font-weight:600;height:18px;line-height:18px;border-radius:3px;padding:0 6px;color:var(--c-bg);vertical-align:top;transition:color var(--t-color),background-color var(--t-color)}.badge.tip{background-color:var(--c-badge-tip)}.badge.warning{background-color:var(--c-badge-warning);color:var(--c-badge-warning-text)}.badge.danger{background-color:var(--c-badge-danger);color:var(--c-badge-danger-text)}.badge+.badge{margin-left:5px}code[class*=language-],pre[class*=language-]{color:#ccc;background:none;font-family:var(--font-family-code);font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.comment,.token.block-comment,.token.prolog,.token.doctype,.token.cdata{color:#999}.token.punctuation{color:#ccc}.token.tag,.token.attr-name,.token.namespace,.token.deleted{color:#ec5975}.token.function-name{color:#6196cc}.token.boolean,.token.number,.token.function{color:#f08d49}.token.property,.token.class-name,.token.constant,.token.symbol{color:#f8c555}.token.selector,.token.important,.token.atrule,.token.keyword,.token.builtin{color:#cc99cd}.token.string,.token.char,.token.attr-value,.token.regex,.token.variable{color:#7ec699}.token.operator,.token.entity,.token.url{color:#67cdcc}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:#3eaf7c}.theme-default-content pre,.theme-default-content pre[class*=language-]{line-height:1.375;padding:1.3rem 1.5rem;margin:.85rem 0;border-radius:6px;overflow:auto}.theme-default-content pre code,.theme-default-content pre[class*=language-] code{color:#fff;padding:0;background-color:transparent!important;border-radius:0;overflow-wrap:unset;-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.theme-default-content .line-number{font-family:var(--font-family-code)}div[class*=language-]{position:relative;background-color:var(--code-bg-color);border-radius:6px}div[class*=language-]:before{content:attr(data-ext);position:absolute;z-index:3;top:.8em;right:1em;font-size:.75rem;color:var(--code-ln-color)}div[class*=language-] pre,div[class*=language-] pre[class*=language-]{background:transparent!important;position:relative;z-index:1}div[class*=language-] .highlight-lines{-webkit-user-select:none;-moz-user-select:none;user-select:none;padding-top:1.3rem;position:absolute;top:0;left:0;width:100%;line-height:1.375}div[class*=language-] .highlight-lines .highlight-line{background-color:var(--code-hl-bg-color)}div[class*=language-]:not(.line-numbers-mode) .line-numbers{display:none}div[class*=language-].line-numbers-mode .highlight-lines .highlight-line{position:relative}div[class*=language-].line-numbers-mode .highlight-lines .highlight-line:before{content:" ";position:absolute;z-index:2;left:0;top:0;display:block;width:var(--code-ln-wrapper-width);height:100%}div[class*=language-].line-numbers-mode pre{margin-left:var(--code-ln-wrapper-width);padding-left:1rem;vertical-align:middle}div[class*=language-].line-numbers-mode .line-numbers{position:absolute;top:0;width:var(--code-ln-wrapper-width);text-align:center;color:var(--code-ln-color);padding-top:1.25rem;line-height:1.375;counter-reset:line-number}div[class*=language-].line-numbers-mode .line-numbers .line-number{position:relative;z-index:3;-webkit-user-select:none;-moz-user-select:none;user-select:none;height:1.375em}div[class*=language-].line-numbers-mode .line-numbers .line-number:before{counter-increment:line-number;content:counter(line-number);font-size:.85em}div[class*=language-].line-numbers-mode:after{content:"";position:absolute;top:0;left:0;width:var(--code-ln-wrapper-width);height:100%;border-radius:6px 0 0 6px;border-right:1px solid var(--code-hl-bg-color)}@media (max-width: 419px){.theme-default-content div[class*=language-]{margin:.85rem -1.5rem;border-radius:0}}.code-group__nav{margin-top:.85rem;margin-bottom:calc(-1.7rem - 6px);padding-bottom:calc(1.7rem - 6px);padding-left:10px;padding-top:10px;border-top-left-radius:6px;border-top-right-radius:6px;background-color:var(--code-bg-color)}.code-group__ul{margin:auto 0;padding-left:0;display:inline-flex;list-style:none}.code-group__nav-tab{border:0;padding:5px;cursor:pointer;background-color:transparent;font-size:.85em;line-height:1.4;color:#ffffffe6;font-weight:600}.code-group__nav-tab:focus{outline:none}.code-group__nav-tab:focus-visible{outline:1px solid rgba(255,255,255,.9)}.code-group__nav-tab-active{border-bottom:var(--c-brand) 1px solid}@media (max-width: 419px){.code-group__nav{margin-left:-1.5rem;margin-right:-1.5rem;border-radius:0}}.code-group-item{display:none}.code-group-item__active{display:block}.code-group-item>pre{background-color:orange}.custom-container{transition:color var(--t-color),border-color var(--t-color),background-color var(--t-color)}.custom-container .custom-container-title{font-weight:600}.custom-container .custom-container-title:not(:only-child){margin-bottom:-.4rem}.custom-container.tip,.custom-container.warning,.custom-container.danger{padding:.1rem 1.5rem;border-left-width:.5rem;border-left-style:solid;margin:1rem 0}.custom-container.tip{border-color:var(--c-tip);background-color:var(--c-tip-bg);color:var(--c-tip-text)}.custom-container.tip .custom-container-title{color:var(--c-tip-title)}.custom-container.tip a{color:var(--c-tip-text-accent)}.custom-container.tip code{background-color:var(--c-bg-dark)}.custom-container.warning{border-color:var(--c-warning);background-color:var(--c-warning-bg);color:var(--c-warning-text)}.custom-container.warning .custom-container-title{color:var(--c-warning-title)}.custom-container.warning a{color:var(--c-warning-text-accent)}.custom-container.warning blockquote{border-left-color:var(--c-warning-border-dark);color:var(--c-warning-text-quote)}.custom-container.warning code{color:var(--c-warning-text-light);background-color:var(--c-warning-bg-light)}.custom-container.warning details{background-color:var(--c-warning-details-bg)}.custom-container.warning details code{background-color:var(--c-warning-bg-lighter)}.custom-container.warning .external-link-icon{--external-link-icon-color: var(--c-warning-text-quote)}.custom-container.danger{border-color:var(--c-danger);background-color:var(--c-danger-bg);color:var(--c-danger-text)}.custom-container.danger .custom-container-title{color:var(--c-danger-title)}.custom-container.danger a{color:var(--c-danger-text-accent)}.custom-container.danger blockquote{border-left-color:var(--c-danger-border-dark);color:var(--c-danger-text-quote)}.custom-container.danger code{color:var(--c-danger-text-light);background-color:var(--c-danger-bg-light)}.custom-container.danger details{background-color:var(--c-danger-details-bg)}.custom-container.danger details code{background-color:var(--c-danger-bg-lighter)}.custom-container.danger .external-link-icon{--external-link-icon-color: var(--c-danger-text-quote)}.custom-container.details{display:block;position:relative;border-radius:2px;margin:1.6em 0;padding:1.6em;background-color:var(--c-details-bg)}.custom-container.details code{background-color:var(--c-bg-darker)}.custom-container.details h4{margin-top:0}.custom-container.details figure:last-child,.custom-container.details p:last-child{margin-bottom:0;padding-bottom:0}.custom-container.details summary{outline:none;cursor:pointer}.home{padding:var(--navbar-height) 2rem 0;max-width:var(--homepage-width);margin:0 auto;display:block}.home .hero{text-align:center}.home .hero img{max-width:100%;max-height:280px;display:block;margin:3rem auto 1.5rem}.home .hero h1{font-size:3rem}.home .hero h1,.home .hero .description,.home .hero .actions{margin:1.8rem auto}.home .hero .actions{display:flex;flex-wrap:wrap;gap:1rem;justify-content:center}.home .hero .description{max-width:35rem;font-size:1.6rem;line-height:1.3;color:var(--c-text-lightest)}.home .hero .action-button{display:inline-block;font-size:1.2rem;padding:.8rem 1.6rem;border-width:2px;border-style:solid;border-radius:4px;transition:background-color var(--t-color);box-sizing:border-box}.home .hero .action-button.primary{color:var(--c-bg);background-color:var(--c-brand);border-color:var(--c-brand)}.home .hero .action-button.primary:hover{background-color:var(--c-brand-light)}.home .hero .action-button.secondary{color:var(--c-brand);background-color:var(--c-bg);border-color:var(--c-brand)}.home .hero .action-button.secondary:hover{color:var(--c-bg);background-color:var(--c-brand-light)}.home .features{border-top:1px solid var(--c-border);transition:border-color var(--t-color);padding:1.2rem 0;margin-top:2.5rem;display:flex;flex-wrap:wrap;align-items:flex-start;align-content:stretch;justify-content:space-between}.home .feature{flex-grow:1;flex-basis:30%;max-width:30%}.home .feature h2{font-size:1.4rem;font-weight:500;border-bottom:none;padding-bottom:0;color:var(--c-text-light)}.home .feature p{color:var(--c-text-lighter)}.home .theme-default-content{padding:0;margin:0}.home .footer{padding:2.5rem;border-top:1px solid var(--c-border);text-align:center;color:var(--c-text-lighter);transition:border-color var(--t-color)}@media (max-width: 719px){.home .features{flex-direction:column}.home .feature{max-width:100%;padding:0 2.5rem}}@media (max-width: 419px){.home{padding-left:1.5rem;padding-right:1.5rem}.home .hero img{max-height:210px;margin:2rem auto 1.2rem}.home .hero h1{font-size:2rem}.home .hero h1,.home .hero .description,.home .hero .actions{margin:1.2rem auto}.home .hero .description{font-size:1.2rem}.home .hero .action-button{font-size:1rem;padding:.6rem 1.2rem}.home .feature h2{font-size:1.25rem}}.page{padding-top:var(--navbar-height);padding-left:var(--sidebar-width)}.navbar{position:fixed;z-index:20;top:0;left:0;right:0;height:var(--navbar-height);box-sizing:border-box;border-bottom:1px solid var(--c-border);background-color:var(--c-bg-navbar);transition:background-color var(--t-color),border-color var(--t-color)}.sidebar{font-size:16px;width:var(--sidebar-width);position:fixed;z-index:10;margin:0;top:var(--navbar-height);left:0;bottom:0;box-sizing:border-box;border-right:1px solid var(--c-border);overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--c-brand) var(--c-border);background-color:var(--c-bg-sidebar);transition:transform var(--t-transform),background-color var(--t-color),border-color var(--t-color)}.sidebar::-webkit-scrollbar{width:7px}.sidebar::-webkit-scrollbar-track{background-color:var(--c-border)}.sidebar::-webkit-scrollbar-thumb{background-color:var(--c-brand)}.sidebar-mask{position:fixed;z-index:9;top:0;left:0;width:100vw;height:100vh;display:none}.theme-container.sidebar-open .sidebar-mask{display:block}.theme-container.sidebar-open .navbar>.toggle-sidebar-button .icon span:nth-child(1){transform:rotate(45deg) translate3d(5.5px,5.5px,0)}.theme-container.sidebar-open .navbar>.toggle-sidebar-button .icon span:nth-child(2){transform:scale3d(0,1,1)}.theme-container.sidebar-open .navbar>.toggle-sidebar-button .icon span:nth-child(3){transform:rotate(-45deg) translate3d(6px,-6px,0)}.theme-container.sidebar-open .navbar>.toggle-sidebar-button .icon span:nth-child(1),.theme-container.sidebar-open .navbar>.toggle-sidebar-button .icon span:nth-child(3){transform-origin:center}.theme-container.no-navbar .theme-default-content h1,.theme-container.no-navbar .theme-default-content h2,.theme-container.no-navbar .theme-default-content h3,.theme-container.no-navbar .theme-default-content h4,.theme-container.no-navbar .theme-default-content h5,.theme-container.no-navbar .theme-default-content h6{margin-top:1.5rem;padding-top:0}.theme-container.no-navbar .page{padding-top:0}.theme-container.no-navbar .sidebar{top:0}.theme-container.no-sidebar .sidebar{display:none}@media (max-width: 719px){.theme-container.no-sidebar .sidebar{display:block}}.theme-container.no-sidebar .page{padding-left:0}.theme-default-content a:hover{text-decoration:underline}.theme-default-content img{max-width:100%}.theme-default-content h1,.theme-default-content h2,.theme-default-content h3,.theme-default-content h4,.theme-default-content h5,.theme-default-content h6{margin-top:calc(.5rem - var(--navbar-height));padding-top:calc(1rem + var(--navbar-height));margin-bottom:0}.theme-default-content h1:first-child,.theme-default-content h2:first-child,.theme-default-content h3:first-child,.theme-default-content h4:first-child,.theme-default-content h5:first-child,.theme-default-content h6:first-child{margin-bottom:1rem}.theme-default-content h1:first-child+p,.theme-default-content h1:first-child+pre,.theme-default-content h1:first-child+.custom-container,.theme-default-content h2:first-child+p,.theme-default-content h2:first-child+pre,.theme-default-content h2:first-child+.custom-container,.theme-default-content h3:first-child+p,.theme-default-content h3:first-child+pre,.theme-default-content h3:first-child+.custom-container,.theme-default-content h4:first-child+p,.theme-default-content h4:first-child+pre,.theme-default-content h4:first-child+.custom-container,.theme-default-content h5:first-child+p,.theme-default-content h5:first-child+pre,.theme-default-content h5:first-child+.custom-container,.theme-default-content h6:first-child+p,.theme-default-content h6:first-child+pre,.theme-default-content h6:first-child+.custom-container{margin-top:2rem}@media (max-width: 959px){.sidebar{font-size:15px;width:var(--sidebar-width-mobile)}.page{padding-left:var(--sidebar-width-mobile)}}@media (max-width: 719px){.sidebar{top:0;padding-top:var(--navbar-height);transform:translate(-100%)}.page{padding-left:0}.theme-container.sidebar-open .sidebar{transform:translate(0)}.theme-container.no-navbar .sidebar{padding-top:0}}@media (max-width: 419px){h1{font-size:1.9rem}}.navbar{--navbar-line-height: calc( var(--navbar-height) - 2 * var(--navbar-padding-v) );padding:var(--navbar-padding-v) var(--navbar-padding-h);line-height:var(--navbar-line-height)}.navbar .logo{height:var(--navbar-line-height);margin-right:var(--navbar-padding-v);vertical-align:top}.navbar .site-name{font-size:1.3rem;font-weight:600;color:var(--c-text);position:relative}.navbar .navbar-items-wrapper{display:flex;position:absolute;box-sizing:border-box;top:var(--navbar-padding-v);right:var(--navbar-padding-h);height:var(--navbar-line-height);padding-left:var(--navbar-padding-h);white-space:nowrap;font-size:.9rem}.navbar .navbar-items-wrapper .search-box{flex:0 0 auto;vertical-align:top}@media screen and (max-width: 719px){.navbar{padding-left:4rem}.navbar .site-name{display:block;width:calc(100vw - 11rem);overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.navbar .can-hide{display:none}}.navbar-items{display:inline-block}@media print{.navbar-items{display:none}}.navbar-items a{display:inline-block;line-height:1.4rem;color:inherit}.navbar-items a:hover,.navbar-items a.router-link-active{color:var(--c-text)}.navbar-items .navbar-item{position:relative;display:inline-block;margin-left:1.5rem;line-height:var(--navbar-line-height)}.navbar-items .navbar-item:first-child{margin-left:0}.navbar-items .navbar-item>a:hover,.navbar-items .navbar-item>a.router-link-active{margin-bottom:-2px;border-bottom:2px solid var(--c-text-accent)}@media (max-width: 719px){.navbar-items .navbar-item{margin-left:0}.navbar-items .navbar-item>a:hover,.navbar-items .navbar-item>a.router-link-active{margin-bottom:0;border-bottom:none}.navbar-items a:hover,.navbar-items a.router-link-active{color:var(--c-text-accent)}}.toggle-sidebar-button{position:absolute;top:.6rem;left:1rem;display:none;padding:.6rem;cursor:pointer}.toggle-sidebar-button .icon{display:flex;flex-direction:column;justify-content:center;align-items:center;width:1.25rem;height:1.25rem;cursor:inherit}.toggle-sidebar-button .icon span{display:inline-block;width:100%;height:2px;border-radius:2px;background-color:var(--c-text);transition:transform var(--t-transform)}.toggle-sidebar-button .icon span:nth-child(2){margin:6px 0}@media screen and (max-width: 719px){.toggle-sidebar-button{display:block}}.toggle-color-mode-button{display:flex;margin:auto;margin-left:1rem;border:0;background:none;color:var(--c-text);opacity:.8;cursor:pointer}@media print{.toggle-color-mode-button{display:none}}.toggle-color-mode-button:hover{opacity:1}.toggle-color-mode-button .icon{width:1.25rem;height:1.25rem}.DocSearch{transition:background-color var(--t-color)}.navbar-dropdown-wrapper{cursor:pointer}.navbar-dropdown-wrapper .navbar-dropdown-title,.navbar-dropdown-wrapper .navbar-dropdown-title-mobile{display:block;font-size:.9rem;font-family:inherit;cursor:inherit;padding:inherit;line-height:1.4rem;background:transparent;border:none;font-weight:500;color:var(--c-text)}.navbar-dropdown-wrapper .navbar-dropdown-title:hover,.navbar-dropdown-wrapper .navbar-dropdown-title-mobile:hover{border-color:transparent}.navbar-dropdown-wrapper .navbar-dropdown-title .arrow,.navbar-dropdown-wrapper .navbar-dropdown-title-mobile .arrow{vertical-align:middle;margin-top:-1px;margin-left:.4rem}.navbar-dropdown-wrapper .navbar-dropdown-title-mobile{display:none;font-weight:600;font-size:inherit}.navbar-dropdown-wrapper .navbar-dropdown-title-mobile:hover{color:var(--c-text-accent)}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item{color:inherit;line-height:1.7rem}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle{margin:.45rem 0 0;border-top:1px solid var(--c-border);padding:1rem 0 .45rem;font-size:.9rem}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle>span{padding:0 1.5rem 0 1.25rem}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle>a{font-weight:inherit}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle>a.router-link-active:after{display:none}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subitem-wrapper{padding:0;list-style:none}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subitem-wrapper .navbar-dropdown-subitem{font-size:.9em}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item a{display:block;line-height:1.7rem;position:relative;border-bottom:none;font-weight:400;margin-bottom:0;padding:0 1.5rem 0 1.25rem}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item a:hover,.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item a.router-link-active{color:var(--c-text-accent)}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item a.router-link-active:after{content:"";width:0;height:0;border-left:5px solid var(--c-text-accent);border-top:3px solid transparent;border-bottom:3px solid transparent;position:absolute;top:calc(50% - 2px);left:9px}.navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item:first-child .navbar-dropdown-subtitle{margin-top:0;padding-top:0;border-top:0}.navbar-dropdown-wrapper.mobile.open .navbar-dropdown-title,.navbar-dropdown-wrapper.mobile.open .navbar-dropdown-title-mobile{margin-bottom:.5rem}.navbar-dropdown-wrapper.mobile .navbar-dropdown-title,.navbar-dropdown-wrapper.mobile .navbar-dropdown-title-mobile{display:none}.navbar-dropdown-wrapper.mobile .navbar-dropdown-title-mobile{display:block}.navbar-dropdown-wrapper.mobile .navbar-dropdown{transition:height .1s ease-out;overflow:hidden}.navbar-dropdown-wrapper.mobile .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle{border-top:0;margin-top:0;padding-top:0;padding-bottom:0}.navbar-dropdown-wrapper.mobile .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subtitle,.navbar-dropdown-wrapper.mobile .navbar-dropdown .navbar-dropdown-item>a{font-size:15px;line-height:2rem}.navbar-dropdown-wrapper.mobile .navbar-dropdown .navbar-dropdown-item .navbar-dropdown-subitem{font-size:14px;padding-left:1rem}.navbar-dropdown-wrapper:not(.mobile){height:1.8rem}.navbar-dropdown-wrapper:not(.mobile):hover .navbar-dropdown,.navbar-dropdown-wrapper:not(.mobile).open .navbar-dropdown{display:block!important}.navbar-dropdown-wrapper:not(.mobile).open:blur{display:none}.navbar-dropdown-wrapper:not(.mobile) .navbar-dropdown{display:none;height:auto!important;box-sizing:border-box;max-height:calc(100vh - 2.7rem);overflow-y:auto;position:absolute;top:100%;right:0;background-color:var(--c-bg-navbar);padding:.6rem 0;border:1px solid var(--c-border);border-bottom-color:var(--c-border-dark);text-align:left;border-radius:.25rem;white-space:nowrap;margin:0}.page{padding-bottom:2rem;display:block}.page .theme-default-content{max-width:var(--content-width);margin:0 auto;padding:2rem 2.5rem;padding-top:0}@media (max-width: 959px){.page .theme-default-content{padding:2rem}}@media (max-width: 419px){.page .theme-default-content{padding:1.5rem}}.page-meta{max-width:var(--content-width);margin:0 auto;padding:1rem 2.5rem;overflow:auto}@media (max-width: 959px){.page-meta{padding:2rem}}@media (max-width: 419px){.page-meta{padding:1.5rem}}.page-meta .meta-item{cursor:default;margin-top:.8rem}.page-meta .meta-item .meta-item-label{font-weight:500;color:var(--c-text-lighter)}.page-meta .meta-item .meta-item-info{font-weight:400;color:var(--c-text-quote)}.page-meta .edit-link{display:inline-block;margin-right:.25rem}@media print{.page-meta .edit-link{display:none}}.page-meta .last-updated{float:right}@media (max-width: 719px){.page-meta .last-updated{font-size:.8em;float:none}.page-meta .contributors{font-size:.8em}}.page-nav{max-width:var(--content-width);margin:0 auto;padding:1rem 2.5rem 2rem;padding-bottom:0}@media (max-width: 959px){.page-nav{padding:2rem}}@media (max-width: 419px){.page-nav{padding:1.5rem}}.page-nav .inner{min-height:2rem;margin-top:0;border-top:1px solid var(--c-border);transition:border-color var(--t-color);padding-top:1rem;overflow:auto}.page-nav .prev a:before{content:"←"}.page-nav .next{float:right}.page-nav .next a:after{content:"→"}.sidebar ul{padding:0;margin:0;list-style-type:none}.sidebar a{display:inline-block}.sidebar .navbar-items{display:none;border-bottom:1px solid var(--c-border);transition:border-color var(--t-color);padding:.5rem 0 .75rem}.sidebar .navbar-items a{font-weight:600}.sidebar .navbar-items .navbar-item{display:block;line-height:1.25rem;font-size:1.1em;padding:.5rem 0 .5rem 1.5rem}.sidebar .sidebar-items{padding:1.5rem 0}@media (max-width: 719px){.sidebar .navbar-items{display:block}.sidebar .navbar-items .navbar-dropdown-wrapper .navbar-dropdown .navbar-dropdown-item a.router-link-active:after{top:calc(1rem - 2px)}.sidebar .sidebar-items{padding:1rem 0}}.sidebar-item{cursor:default;border-left:.25rem solid transparent;color:var(--c-text)}.sidebar-item:focus-visible{outline-width:1px;outline-offset:-1px}.sidebar-item.active:not(p.sidebar-heading){font-weight:600;color:var(--c-text-accent);border-left-color:var(--c-text-accent)}.sidebar-item.sidebar-heading{transition:color .15s ease;font-size:1.1em;font-weight:700;padding:.35rem 1.5rem .35rem 1.25rem;width:100%;box-sizing:border-box;margin:0}.sidebar-item.sidebar-heading+.sidebar-item-children{transition:height .1s ease-out;overflow:hidden;margin-bottom:.75rem}.sidebar-item.collapsible{cursor:pointer}.sidebar-item.collapsible .arrow{position:relative;top:-.12em;left:.5em}.sidebar-item:not(.sidebar-heading){font-size:1em;font-weight:400;display:inline-block;margin:0;padding:.35rem 1rem .35rem 2rem;line-height:1.4;width:100%;box-sizing:border-box}.sidebar-item:not(.sidebar-heading)+.sidebar-item-children{padding-left:1rem;font-size:.95em}.sidebar-item-children .sidebar-item-children .sidebar-item:not(.sidebar-heading){padding:.25rem 1rem .25rem 1.75rem}.sidebar-item-children .sidebar-item-children .sidebar-item:not(.sidebar-heading).active{font-weight:500;border-left-color:transparent}a.sidebar-heading+.sidebar-item-children .sidebar-item:not(.sidebar-heading).active{border-left-color:transparent}a.sidebar-item{cursor:pointer}a.sidebar-item:hover{color:var(--c-text-accent)}.table-of-contents .badge{vertical-align:middle}.dropdown-enter-from,.dropdown-leave-to{height:0!important}.fade-slide-y-enter-active{transition:all .2s ease}.fade-slide-y-leave-active{transition:all .2s cubic-bezier(1,.5,.8,1)}.fade-slide-y-enter-from,.fade-slide-y-leave-to{transform:translateY(10px);opacity:0}svg[data-v-1b705319]{position:absolute;right:7.5px;opacity:.75;cursor:pointer;z-index:1}svg.hover[data-v-1b705319]{opacity:0}svg[data-v-1b705319]:hover{opacity:1!important}span[data-v-1b705319]{position:absolute;font-size:.85rem;line-height:1.2rem;right:40px;opacity:0;transition:opacity .5s}.success[data-v-1b705319]{opacity:1!important}.code-copy-added:hover>div>.code-copy svg{opacity:.75}.home .hero img{margin-top:0;height:100%}.tg{border-collapse:collapse;border-color:#ccc;border-spacing:0;margin:0 auto}.tg td{background-color:#fff;border-color:#ccc;border-style:solid;border-width:1px;color:#333;font-family:Arial,sans-serif;font-size:14px;overflow:hidden;padding:10px 5px;word-break:normal}.tg th{background-color:#f0f0f0;border-color:#ccc;border-style:solid;border-width:1px;color:#333;font-family:Arial,sans-serif;font-size:14px;font-weight:400;overflow:hidden;padding:10px 5px;word-break:normal}.tg .tg-c3ow{border-color:inherit;text-align:center;vertical-align:top}.tg .tg-0pky{border-color:inherit;text-align:left;vertical-align:top}.tg .tg-btxf{background-color:#f9f9f9;border-color:inherit;text-align:left;vertical-align:top}.tg .tg-abip{background-color:#f9f9f9;border-color:inherit;text-align:center;vertical-align:top}/*! @docsearch/css 3.5.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */:root{--docsearch-primary-color:#5468ff;--docsearch-text-color:#1c1e21;--docsearch-spacing:12px;--docsearch-icon-stroke-width:1.4;--docsearch-highlight-color:var(--docsearch-primary-color);--docsearch-muted-color:#969faf;--docsearch-container-background:rgba(101,108,133,.8);--docsearch-logo-color:#5468ff;--docsearch-modal-width:560px;--docsearch-modal-height:600px;--docsearch-modal-background:#f5f6f7;--docsearch-modal-shadow:inset 1px 1px 0 0 hsla(0,0%,100%,.5),0 3px 8px 0 #555a64;--docsearch-searchbox-height:56px;--docsearch-searchbox-background:#ebedf0;--docsearch-searchbox-focus-background:#fff;--docsearch-searchbox-shadow:inset 0 0 0 2px var(--docsearch-primary-color);--docsearch-hit-height:56px;--docsearch-hit-color:#444950;--docsearch-hit-active-color:#fff;--docsearch-hit-background:#fff;--docsearch-hit-shadow:0 1px 3px 0 #d4d9e1;--docsearch-key-gradient:linear-gradient(-225deg,#d5dbe4,#f8f8f8);--docsearch-key-shadow:inset 0 -2px 0 0 #cdcde6,inset 0 0 1px 1px #fff,0 1px 2px 1px rgba(30,35,90,.4);--docsearch-footer-height:44px;--docsearch-footer-background:#fff;--docsearch-footer-shadow:0 -1px 0 0 #e0e3e8,0 -3px 6px 0 rgba(69,98,155,.12)}html[data-theme=dark]{--docsearch-text-color:#f5f6f7;--docsearch-container-background:rgba(9,10,17,.8);--docsearch-modal-background:#15172a;--docsearch-modal-shadow:inset 1px 1px 0 0 #2c2e40,0 3px 8px 0 #000309;--docsearch-searchbox-background:#090a11;--docsearch-searchbox-focus-background:#000;--docsearch-hit-color:#bec3c9;--docsearch-hit-shadow:none;--docsearch-hit-background:#090a11;--docsearch-key-gradient:linear-gradient(-26.5deg,#565872,#31355b);--docsearch-key-shadow:inset 0 -2px 0 0 #282d55,inset 0 0 1px 1px #51577d,0 2px 2px 0 rgba(3,4,9,.3);--docsearch-footer-background:#1e2136;--docsearch-footer-shadow:inset 0 1px 0 0 rgba(73,76,106,.5),0 -4px 8px 0 rgba(0,0,0,.2);--docsearch-logo-color:#fff;--docsearch-muted-color:#7f8497}.DocSearch-Button{align-items:center;background:var(--docsearch-searchbox-background);border:0;border-radius:40px;color:var(--docsearch-muted-color);cursor:pointer;display:flex;font-weight:500;height:36px;justify-content:space-between;margin:0 0 0 16px;padding:0 8px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.DocSearch-Button:active,.DocSearch-Button:focus,.DocSearch-Button:hover{background:var(--docsearch-searchbox-focus-background);box-shadow:var(--docsearch-searchbox-shadow);color:var(--docsearch-text-color);outline:none}.DocSearch-Button-Container{align-items:center;display:flex}.DocSearch-Search-Icon{stroke-width:1.6}.DocSearch-Button .DocSearch-Search-Icon{color:var(--docsearch-text-color)}.DocSearch-Button-Placeholder{font-size:1rem;padding:0 12px 0 6px}.DocSearch-Button-Keys{display:flex;min-width:calc(40px + .8em)}.DocSearch-Button-Key{align-items:center;background:var(--docsearch-key-gradient);border-radius:3px;box-shadow:var(--docsearch-key-shadow);color:var(--docsearch-muted-color);display:flex;height:18px;justify-content:center;margin-right:.4em;position:relative;padding:0 0 2px;border:0;top:-1px;width:20px}@media (max-width:768px){.DocSearch-Button-Keys,.DocSearch-Button-Placeholder{display:none}}.DocSearch--active{overflow:hidden!important}.DocSearch-Container,.DocSearch-Container *{box-sizing:border-box}.DocSearch-Container{background-color:var(--docsearch-container-background);height:100vh;left:0;position:fixed;top:0;width:100vw;z-index:200}.DocSearch-Container a{text-decoration:none}.DocSearch-Link{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;color:var(--docsearch-highlight-color);cursor:pointer;font:inherit;margin:0;padding:0}.DocSearch-Modal{background:var(--docsearch-modal-background);border-radius:6px;box-shadow:var(--docsearch-modal-shadow);flex-direction:column;margin:60px auto auto;max-width:var(--docsearch-modal-width);position:relative}.DocSearch-SearchBar{display:flex;padding:var(--docsearch-spacing) var(--docsearch-spacing) 0}.DocSearch-Form{align-items:center;background:var(--docsearch-searchbox-focus-background);border-radius:4px;box-shadow:var(--docsearch-searchbox-shadow);display:flex;height:var(--docsearch-searchbox-height);margin:0;padding:0 var(--docsearch-spacing);position:relative;width:100%}.DocSearch-Input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:transparent;border:0;color:var(--docsearch-text-color);flex:1;font:inherit;font-size:1.2em;height:100%;outline:none;padding:0 0 0 8px;width:80%}.DocSearch-Input::-moz-placeholder{color:var(--docsearch-muted-color);opacity:1}.DocSearch-Input::placeholder{color:var(--docsearch-muted-color);opacity:1}.DocSearch-Input::-webkit-search-cancel-button,.DocSearch-Input::-webkit-search-decoration,.DocSearch-Input::-webkit-search-results-button,.DocSearch-Input::-webkit-search-results-decoration{display:none}.DocSearch-LoadingIndicator,.DocSearch-MagnifierLabel,.DocSearch-Reset{margin:0;padding:0}.DocSearch-MagnifierLabel,.DocSearch-Reset{align-items:center;color:var(--docsearch-highlight-color);display:flex;justify-content:center}.DocSearch-Container--Stalled .DocSearch-MagnifierLabel,.DocSearch-LoadingIndicator{display:none}.DocSearch-Container--Stalled .DocSearch-LoadingIndicator{align-items:center;color:var(--docsearch-highlight-color);display:flex;justify-content:center}@media screen and (prefers-reduced-motion:reduce){.DocSearch-Reset{animation:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;border-radius:50%;color:var(--docsearch-icon-color);cursor:pointer;right:0;stroke-width:var(--docsearch-icon-stroke-width)}}.DocSearch-Reset{animation:fade-in .1s ease-in forwards;-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;border-radius:50%;color:var(--docsearch-icon-color);cursor:pointer;padding:2px;right:0;stroke-width:var(--docsearch-icon-stroke-width)}.DocSearch-Reset[hidden]{display:none}.DocSearch-Reset:hover{color:var(--docsearch-highlight-color)}.DocSearch-LoadingIndicator svg,.DocSearch-MagnifierLabel svg{height:24px;width:24px}.DocSearch-Cancel{display:none}.DocSearch-Dropdown{max-height:calc(var(--docsearch-modal-height) - var(--docsearch-searchbox-height) - var(--docsearch-spacing) - var(--docsearch-footer-height));min-height:var(--docsearch-spacing);overflow-y:auto;overflow-y:overlay;padding:0 var(--docsearch-spacing);scrollbar-color:var(--docsearch-muted-color) var(--docsearch-modal-background);scrollbar-width:thin}.DocSearch-Dropdown::-webkit-scrollbar{width:12px}.DocSearch-Dropdown::-webkit-scrollbar-track{background:transparent}.DocSearch-Dropdown::-webkit-scrollbar-thumb{background-color:var(--docsearch-muted-color);border:3px solid var(--docsearch-modal-background);border-radius:20px}.DocSearch-Dropdown ul{list-style:none;margin:0;padding:0}.DocSearch-Label{font-size:.75em;line-height:1.6em}.DocSearch-Help,.DocSearch-Label{color:var(--docsearch-muted-color)}.DocSearch-Help{font-size:.9em;margin:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.DocSearch-Title{font-size:1.2em}.DocSearch-Logo a{display:flex}.DocSearch-Logo svg{color:var(--docsearch-logo-color);margin-left:8px}.DocSearch-Hits:last-of-type{margin-bottom:24px}.DocSearch-Hits mark{background:none;color:var(--docsearch-highlight-color)}.DocSearch-HitsFooter{color:var(--docsearch-muted-color);display:flex;font-size:.85em;justify-content:center;margin-bottom:var(--docsearch-spacing);padding:var(--docsearch-spacing)}.DocSearch-HitsFooter a{border-bottom:1px solid;color:inherit}.DocSearch-Hit{border-radius:4px;display:flex;padding-bottom:4px;position:relative}@media screen and (prefers-reduced-motion:reduce){.DocSearch-Hit--deleting{transition:none}}.DocSearch-Hit--deleting{opacity:0;transition:all .25s linear}@media screen and (prefers-reduced-motion:reduce){.DocSearch-Hit--favoriting{transition:none}}.DocSearch-Hit--favoriting{transform:scale(0);transform-origin:top center;transition:all .25s linear;transition-delay:.25s}.DocSearch-Hit a{background:var(--docsearch-hit-background);border-radius:4px;box-shadow:var(--docsearch-hit-shadow);display:block;padding-left:var(--docsearch-spacing);width:100%}.DocSearch-Hit-source{background:var(--docsearch-modal-background);color:var(--docsearch-highlight-color);font-size:.85em;font-weight:600;line-height:32px;margin:0 -4px;padding:8px 4px 0;position:sticky;top:0;z-index:10}.DocSearch-Hit-Tree{color:var(--docsearch-muted-color);height:var(--docsearch-hit-height);opacity:.5;stroke-width:var(--docsearch-icon-stroke-width);width:24px}.DocSearch-Hit[aria-selected=true] a{background-color:var(--docsearch-highlight-color)}.DocSearch-Hit[aria-selected=true] mark{text-decoration:underline}.DocSearch-Hit-Container{align-items:center;color:var(--docsearch-hit-color);display:flex;flex-direction:row;height:var(--docsearch-hit-height);padding:0 var(--docsearch-spacing) 0 0}.DocSearch-Hit-icon{height:20px;width:20px}.DocSearch-Hit-action,.DocSearch-Hit-icon{color:var(--docsearch-muted-color);stroke-width:var(--docsearch-icon-stroke-width)}.DocSearch-Hit-action{align-items:center;display:flex;height:22px;width:22px}.DocSearch-Hit-action svg{display:block;height:18px;width:18px}.DocSearch-Hit-action+.DocSearch-Hit-action{margin-left:6px}.DocSearch-Hit-action-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;border-radius:50%;color:inherit;cursor:pointer;padding:2px}svg.DocSearch-Hit-Select-Icon{display:none}.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-Select-Icon{display:block}.DocSearch-Hit-action-button:focus,.DocSearch-Hit-action-button:hover{background:rgba(0,0,0,.2);transition:background-color .1s ease-in}@media screen and (prefers-reduced-motion:reduce){.DocSearch-Hit-action-button:focus,.DocSearch-Hit-action-button:hover{transition:none}}.DocSearch-Hit-action-button:focus path,.DocSearch-Hit-action-button:hover path{fill:#fff}.DocSearch-Hit-content-wrapper{display:flex;flex:1 1 auto;flex-direction:column;font-weight:500;justify-content:center;line-height:1.2em;margin:0 8px;overflow-x:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:80%}.DocSearch-Hit-title{font-size:.9em}.DocSearch-Hit-path{color:var(--docsearch-muted-color);font-size:.75em}.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-action,.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-icon,.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-path,.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-text,.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-title,.DocSearch-Hit[aria-selected=true] .DocSearch-Hit-Tree,.DocSearch-Hit[aria-selected=true] mark{color:var(--docsearch-hit-active-color)!important}@media screen and (prefers-reduced-motion:reduce){.DocSearch-Hit-action-button:focus,.DocSearch-Hit-action-button:hover{background:rgba(0,0,0,.2);transition:none}}.DocSearch-ErrorScreen,.DocSearch-NoResults,.DocSearch-StartScreen{font-size:.9em;margin:0 auto;padding:36px 0;text-align:center;width:80%}.DocSearch-Screen-Icon{color:var(--docsearch-muted-color);padding-bottom:12px}.DocSearch-NoResults-Prefill-List{display:inline-block;padding-bottom:24px;text-align:left}.DocSearch-NoResults-Prefill-List ul{display:inline-block;padding:8px 0 0}.DocSearch-NoResults-Prefill-List li{list-style-position:inside;list-style-type:"» "}.DocSearch-Prefill{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;border-radius:1em;color:var(--docsearch-highlight-color);cursor:pointer;display:inline-block;font-size:1em;font-weight:700;padding:0}.DocSearch-Prefill:focus,.DocSearch-Prefill:hover{outline:none;text-decoration:underline}.DocSearch-Footer{align-items:center;background:var(--docsearch-footer-background);border-radius:0 0 8px 8px;box-shadow:var(--docsearch-footer-shadow);display:flex;flex-direction:row-reverse;flex-shrink:0;height:var(--docsearch-footer-height);justify-content:space-between;padding:0 var(--docsearch-spacing);position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%;z-index:300}.DocSearch-Commands{color:var(--docsearch-muted-color);display:flex;list-style:none;margin:0;padding:0}.DocSearch-Commands li{align-items:center;display:flex}.DocSearch-Commands li:not(:last-of-type){margin-right:.8em}.DocSearch-Commands-Key{align-items:center;background:var(--docsearch-key-gradient);border-radius:2px;box-shadow:var(--docsearch-key-shadow);display:flex;height:18px;justify-content:center;margin-right:.4em;padding:0 0 1px;color:var(--docsearch-muted-color);border:0;width:20px}@media (max-width:768px){:root{--docsearch-spacing:10px;--docsearch-footer-height:40px}.DocSearch-Dropdown{height:100%}.DocSearch-Container{height:100vh;height:-webkit-fill-available;height:calc(var(--docsearch-vh, 1vh)*100);position:absolute}.DocSearch-Footer{border-radius:0;bottom:0;position:absolute}.DocSearch-Hit-content-wrapper{display:flex;position:relative;width:80%}.DocSearch-Modal{border-radius:0;box-shadow:none;height:100vh;height:-webkit-fill-available;height:calc(var(--docsearch-vh, 1vh)*100);margin:0;max-width:100%;width:100%}.DocSearch-Dropdown{max-height:calc(var(--docsearch-vh, 1vh)*100 - var(--docsearch-searchbox-height) - var(--docsearch-spacing) - var(--docsearch-footer-height))}.DocSearch-Cancel{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:0;color:var(--docsearch-highlight-color);cursor:pointer;display:inline-block;flex:none;font:inherit;font-size:1em;font-weight:500;margin-left:var(--docsearch-spacing);outline:none;overflow:hidden;padding:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap}.DocSearch-Commands,.DocSearch-Hit-Tree{display:none}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@media (min-width: 751px){#docsearch-container{min-width:171.36px}}@media (max-width: 750px){.DocSearch-Container{position:fixed}#docsearch-container{min-width:52px}}@media print{#docsearch-container{display:none}} diff --git a/preview/assets/style-e9220a04.js b/preview/assets/style-e9220a04.js new file mode 100644 index 000000000..c93054809 --- /dev/null +++ b/preview/assets/style-e9220a04.js @@ -0,0 +1 @@ +const t="";export{t as default}; diff --git a/preview/assets/throttle.html-32dcc936.js b/preview/assets/throttle.html-32dcc936.js new file mode 100644 index 000000000..37a704839 --- /dev/null +++ b/preview/assets/throttle.html-32dcc936.js @@ -0,0 +1 @@ +import{_ as r,r as i,o as s,c as l,a as t,b as e,d as n}from"./app-73097456.js";const a={},c={href:"https://github.com/SGrondin/bottleneck",target:"_blank",rel:"noopener noreferrer"},d={href:"https://github.com/luin/ioredis",target:"_blank",rel:"noopener noreferrer"},_=t("em",null,"NotifyBC",-1),h=t("em",null,"jobExpiration",-1),f=t("em",null,"expiration",-1),u={href:"https://github.com/bcgov/NotifyBC/blob/main/src/config.ts",target:"_blank",rel:"noopener noreferrer"},m=t("p",null,[e("When "),t("em",null,"NotifyBC"),e(" is deployed to Kubernetes using Helm, by default throttle, if enabled, uses Redis Sentinel therefore rate limit applies to whole cluster.")],-1);function p(b,g){const o=i("ExternalLinkIcon");return s(),l("div",null,[t("p",null,[e("Throttle is implemented using "),t("a",c,[e("Bottleneck"),n(o)]),e(" and "),t("a",d,[e("ioredis"),n(o)]),e(". See their documentations for more configurations. The only deviation made by "),_,e(" is using "),h,e(" to denote Bottleneck "),f,e(" job option with a default value of 2min as defined in "),t("a",u,[e("config.ts"),n(o)]),e(".")]),m])}const x=r(a,[["render",p],["__file","throttle.html.vue"]]);export{x as default}; diff --git a/preview/assets/throttle.html-4a67c62e.js b/preview/assets/throttle.html-4a67c62e.js new file mode 100644 index 000000000..9dee3dcb2 --- /dev/null +++ b/preview/assets/throttle.html-4a67c62e.js @@ -0,0 +1 @@ +const t=JSON.parse('{"key":"v-4395d380","path":"/docs/shared/throttle.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/throttle.md"}');export{t as data}; diff --git a/preview/assets/whereQueryParam.html-10f3d62d.js b/preview/assets/whereQueryParam.html-10f3d62d.js new file mode 100644 index 000000000..caae343ab --- /dev/null +++ b/preview/assets/whereQueryParam.html-10f3d62d.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-17bf8008","path":"/docs/shared/whereQueryParam.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/whereQueryParam.md"}');export{e as data}; diff --git a/preview/assets/whereQueryParam.html-889e420d.js b/preview/assets/whereQueryParam.html-889e420d.js new file mode 100644 index 000000000..674dd9ec4 --- /dev/null +++ b/preview/assets/whereQueryParam.html-889e420d.js @@ -0,0 +1,10 @@ +import{_ as n,r as o,o as a,c,a as e,b as r,d as s}from"./app-73097456.js";const l={},m=e("em",null,"where",-1),d={href:"https://www.mongodb.com/docs/manual/tutorial/query-documents/",target:"_blank",rel:"noopener noreferrer"},u=e("pre",null,[e("code",null,`- parameter name: where +- required: false +- parameter type: query +- data type: object + +The value can be expressed as either + + 1. URL-encoded stringified JSON object (see example below); or + 2. in the format supported by [qs](https://github.com/ljharb/qs), for example \`?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"\` +`)],-1);function h(p,_){const t=o("ExternalLinkIcon");return a(),c("div",null,[e("p",null,[r("a "),m,r(" query parameter with value conforming to MongoDB "),e("a",d,[r("Query Documents"),s(t)])]),u])}const f=n(l,[["render",h],["__file","whereQueryParam.html.vue"]]);export{f as default}; diff --git a/preview/assets/whereQueryParamCode.html-33176c09.js b/preview/assets/whereQueryParamCode.html-33176c09.js new file mode 100644 index 000000000..d669e4200 --- /dev/null +++ b/preview/assets/whereQueryParamCode.html-33176c09.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-0b4a148f","path":"/docs/shared/whereQueryParamCode.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/whereQueryParamCode.md"}');export{e as data}; diff --git a/preview/assets/whereQueryParamCode.html-84587f03.js b/preview/assets/whereQueryParamCode.html-84587f03.js new file mode 100644 index 000000000..a89dba9e0 --- /dev/null +++ b/preview/assets/whereQueryParamCode.html-84587f03.js @@ -0,0 +1 @@ +import{_ as e,o as t,c as r,a as o}from"./app-73097456.js";const a={},c=o("p",null,"?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D",-1),_=[c];function s(n,l){return t(),r("div",null,_)}const h=e(a,[["render",s],["__file","whereQueryParamCode.html.vue"]]);export{h as default}; diff --git a/preview/assets/whereQueryParamExample.html-10f69adc.js b/preview/assets/whereQueryParamExample.html-10f69adc.js new file mode 100644 index 000000000..ed9589c3d --- /dev/null +++ b/preview/assets/whereQueryParamExample.html-10f69adc.js @@ -0,0 +1,9 @@ +import{_ as n,o as r,c as a,a as e,b as t}from"./app-73097456.js";const o={},c=e("p",null,[t("the value of the "),e("em",null,"where"),t(" query parameter is URL-encoded stringified JSON object")],-1),l=e("pre",null,[e("code",null,`\`\`\`json +{ + "created": { + "$gte": "2023-01-01", + "$lt": "2024-01-01" + } +} +\`\`\` +`)],-1),s=[c,l];function _(d,u){return r(),a("div",null,s)}const m=n(o,[["render",_],["__file","whereQueryParamExample.html.vue"]]);export{m as default}; diff --git a/preview/assets/whereQueryParamExample.html-68248e1c.js b/preview/assets/whereQueryParamExample.html-68248e1c.js new file mode 100644 index 000000000..db0a81baf --- /dev/null +++ b/preview/assets/whereQueryParamExample.html-68248e1c.js @@ -0,0 +1 @@ +const e=JSON.parse('{"key":"v-5119194c","path":"/docs/shared/whereQueryParamExample.html","title":"","lang":"en-US","frontmatter":{},"headers":[],"git":{},"filePathRelative":"docs/shared/whereQueryParamExample.md"}');export{e as data}; diff --git a/preview/attachments/benchmark-email.txt b/preview/attachments/benchmark-email.txt new file mode 100644 index 000000000..376248518 --- /dev/null +++ b/preview/attachments/benchmark-email.txt @@ -0,0 +1,25 @@ +x-sender: no_reply@invlid.local +x-receiver: test@win2012 +Received: from localhost ([xxx.xx.xx.xxx]) by win2012 with Microsoft SMTPSVC(8.0.9200.16384); + Wed, 16 Aug 2017 10:07:44 -0700 +Content-Type: text/html +From: no_reply@invlid.local +To: test@win2012 +Subject: Despite unpopularity, Victoria fadsasdfasd +Message-ID: +X-Mailer: nodemailer (2.7.2; +https://nodemailer.com/; SMTP + (pool)/2.8.2[client:2.12.0]) +Content-Transfer-Encoding: quoted-printable +Date: Wed, 16 Aug 2017 17:07:40 +0000 +MIME-Version: 1.0 +Return-Path: no_reply@invlid.local +X-OriginalArrivalTime: 16 Aug 2017 17:07:44.0566 (UTC) FILETIME=[2D680D60:01D316B2] + +Lorem ipsum dolor sit amet, facete debitis dolores nam eu, nemore = +voluptatum interesset at mel. Duo et legimus vituperata, mei adipisci = +prodesset conclusionemque an. Mnesarchum adversarium eam eu, ad postea = +labore vituperatoribus eam. Dicam convenire vis ei, id vis quod luptatum. = +Expetenda consequat at quo, mel inermis volumus intellegam ut, mei vocibus = +inciderint ea. At error viris has.

    linknsubscription

    diff --git a/preview/docs/acknowledgments/index.html b/preview/docs/acknowledgments/index.html new file mode 100644 index 000000000..21ad0a3e7 --- /dev/null +++ b/preview/docs/acknowledgments/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Acknowledgments | NotifyBC + + + + + + + + diff --git a/preview/docs/api-administrator/index.html b/preview/docs/api-administrator/index.html new file mode 100644 index 000000000..1934746b9 --- /dev/null +++ b/preview/docs/api-administrator/index.html @@ -0,0 +1,142 @@ + + + + + + + + + Administrator | NotifyBC + + + + +

    Administrator

    The administrator API provides knowledge factor authentication to identify admin request by access token (aka API token in other literatures) associated with a registered administrator maintained in NotifyBC database. Because knowledge factor authentication is vulnerable to brute-force attack, administrator API based access token authentication is less favorable than admin ip list, client certificate, or OIDC authentication.

    Avoid Administrator API

    Administrator API was created to circumvent an OpenShift limitation - the source ip of a request initiated from an OpenShift pod cannot be exclusively allocated to the pod's project, rather it has to be shared by all OpenShift projects. Therefore it's difficult to impose granular access control based on source ip.

    With the introduction client certificate in v2.4.0, most use cases, if not all, that need Administrator API including the OpenShift use case mentioned above can be addressed by client certificate. Therefore only use Administrator API sparingly as last resort.

    To enable access token authentication,

    1. a super-admin signs up an administrator

      For example,

      curl -X POST "http://localhost:3000/api/administrators" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{\"username\":\"Foo\",\"email\":\"user@example.com\",\"password\":\"secret\"}"
      +

      The step can also be completed in web console using add button in Administrators panel.

    2. Either super-admin or the user login to generate an access token

      For example,

      curl -X POST "http://localhost:3000/api/administrators/login" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{\"email\":\"user@example.com\",\"password\":\"secret\",\"tokenName\":\"myApp\"}"
      +

      The step can also be completed in web console GUI by an anonymous user using login button at top right corner. Access token generated by GUI is valid for 12hrs.

    3. Apply access token to either Authorization header or access_token query parameter to make authenticated requests. For example, to get a list of notifications

      ACCESS_TOKEN=6Nb2ti5QEXIoDBS5FQGWIz4poRFiBCMMYJbYXSGHWuulOuy0GTEuGx2VCEVvbpBK
      +
      +# Authorization Header
      +curl -X GET -H "Authorization: $ACCESS_TOKEN" \
      +http://localhost:3000/api/notifications
      +
      +# Query Parameter
      +curl -X GET http://localhost:3000/api/notifications?access_token=$ACCESS_TOKEN
      +

      In web console, once login as administrator, the access token is automatically applied.

    Model Schemas

    The Administrator API operates on three related sub-models - Administrator, UserCredential and AccessToken. An administrator has one and only one user credential and zero or more access tokens. Their relationship is diagramed as

    administrator model diagram

    Administrator

    NameAttributes

    id

    typestring, format depends on db
    auto-generatedtrue

    email

    typestring
    requiredtrue
    uniquetrue

    username

    user name

    typestring
    requiredfalse

    UserCredential

    NameAttributes

    id

    typestring, format depends on db
    auto-generatedtrue

    password

    hashed password

    typestring
    requiredtrue

    userId

    foreign key to Administrator model

    typestring
    requiredtrue

    AccessToken

    NameAttributes

    id

    64-byte random alphanumeric characters

    typestring
    auto-generatedtrue

    userId

    foreign key to Administrator model

    typestring
    requiredtrue

    ttl

    Time-to-live in seconds. If absent, access token never expires.

    typenumber
    requiredfalse

    name

    Name of the access token. Can be used to identify applications that use the token.

    typestring
    requiredfalse

    Sign Up

    POST /administrators
    +

    This API allows a super-admin to create an admin.

    • permissions required, one of

      • super admin
    • inputs

      • user information

        {
        +  "email": "string",
        +  "password": "string",
        +  "username": "string"
        +}
        +

        Password must meet following complexity rules:

        • contains at least 10 characters
        • contains at least one lower case character a-z
        • contains at least one upper case character A-Z
        • contains at least one numeric character 0-9
        • contains at lease one special character in !_@#$&*

        email must be unique. username is optional.

        • required: true
        • parameter type: request body
        • data type: object
    • outcome

      • for super-admin requests,
        • an Administrator is generated, populated with email and username
        • a UserCredential is generated, populated with hashed password
        • Administrator is returned
      • forbidden otherwise

    Login

    POST /administrators/login
    +

    This API allows an admin to login and create an access token

    • inputs

      • user information

        {
        +  "email": "user@example.com",
        +  "password": "string",
        +  "tokenName": "string",
        +  "ttl": 0
        +}
        +

        tokenName and ttl are optional. If ttl is absent, access token never expires.

        • required: true
        • parameter type: request body
        • data type: object
    • outcome

      • if login is successful
        • a new AccessToken is generated with tokenName is saved to AccessToken.name and ttl is saved to AccessToken.ttl.
        • the new access token is returned
          {
          +  "token": "string"
          +}
          +
      • forbidden otherwise

    Set Password

    POST /administrators/{id}/user-credential
    +

    This API allows a super-admin or admin to create or update password by id. An admin can only create/update own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • password

        {
        +  "password": "string"
        +}
        +

        The password must meet complexity rules specified in Sign Up.

        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin,
        1. hash the input password
        2. remove any existing UserCredential.password for the Administrator
        3. create a new UserCredential.password
      • forbidden otherwise

    Get Administrators

    GET /administrators
    +

    This API allows a super-admin or admin to search for administrators. An admin can only search for own record

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • a filter containing properties where, fields, order, skip, and limit

        • parameter name: filter
        • required: false
        • parameter type: query
        • data type: object

        The filter can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"

        Regardless, the filter will have to be parsed into a JSON object conforming to

        {
        +    "where": {...},
        +    "fields": ...,
        +    "order": ...,
        +    "skip": ...,
        +    "limit": ...,
        +}
        +

        All properties are optional. The syntax for each property is documented, respectively

    • outcome

      • for super-admins, returns an array of Administrators matching the filter
      • for admins, returns an array of one element - own record if the record matches the filter; empty array otherwise
      • forbidden otherwise
    • example

      to retrieve administrators created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
      +

      the value of the filter query parameter is URL-encoded stringified JSON object

      {
      +  "where": {
      +    "created": {
      +      "$gte": "2023-01-01",
      +      "$lt": "2024-01-01"
      +    }
      +  }
      +}
      +

    Get Administrator Count

    GET /administrators/count
    +

    This API allows a super-admin or admin to count administrators by filter. An admin can only count own record therefore the number is at most 1.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
    • outcome

      • for super-admins or admin, a count matching the filter
      • forbidden otherwise
    • example

      to retrieve the count of administrators created in year 2023 , run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +

    Delete an Administrator

    DELETE /administrators/{id}
    +

    This API allows a super-admin or admin to delete administrator by id. An admin can only delete own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      • for super-admins or admin
        • all AccessToken of the Administrator are deleted
        • the corresponding UserCredential is deleted
        • the Administrator is deleted
      • forbidden otherwise

    Get an Administrator

    GET /administrators/{id}
    +

    This API allows a super-admin or admin to get administrator by id. An admin can only get own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      • for super-admins or admin, returns the Administrator
      • forbidden otherwise

    Update an Administrator

    PATCH /administrators/{id}
    +

    This API allows a super-admin or admin to update administrator fields by id. An admin can only update own record.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • user information

        {
        +  "username": "string",
        +  "email": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin, updates the Administrator
      • forbidden otherwise

    Replace an Administrator

    PUT /administrators/{id}
    +

    This API allows a super-admin or admin to replace administrator records by id. An admin can only replace own record. This API is different from Update an Administrator in that update/patch needs only to contain fields that are changed, ie the delta, whereas replace/put needs to contain all fields to be saved.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • user information

        {
        +  "username": "string",
        +  "email": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin, updates the Administrator. If password is also supplied, the password is handled same way as Set Password API
      • forbidden otherwise

    Get an Administrator's AccessTokens

    GET /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to get access tokens by Administrator id. An admin can only get own records.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • a filter containing properties where, fields, order, skip, and limit

        • parameter name: filter
        • required: false
        • parameter type: query
        • data type: object

        The filter can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"

        Regardless, the filter will have to be parsed into a JSON object conforming to

        {
        +    "where": {...},
        +    "fields": ...,
        +    "order": ...,
        +    "skip": ...,
        +    "limit": ...,
        +}
        +

        All properties are optional. The syntax for each property is documented, respectively

    • outcome

      • for super-admins or admin, a list of AccessTokens matching the filter
      • forbidden otherwise
    • example

      to retrieve access tokens created in year 2023 for administrator with id of 1, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
      +

      the value of the filter query parameter is URL-encoded stringified JSON object

      {
      +  "where": {
      +    "created": {
      +      "$gte": "2023-01-01",
      +      "$lt": "2024-01-01"
      +    }
      +  }
      +}
      +

    Update an Administrator's AccessTokens

    PATCH /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to update access tokens by Administrator id. An admin can only update own records.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
      • AccessToken information

        {
        +  "ttl": 0,
        +  "name": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome

      • for super-admins or admin, success count
      • forbidden otherwise
    • example

      to set ttl token to 0 for all access tokens created in year 2023 for administrator with id 1, run

      curl -X PATCH --header 'Content-Type: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D' -d '{"ttl":0}'
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +

    Create an Administrator's AccessToken

    POST /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to create an access token by Administrator id. An admin can only create own records.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • AccessToken information

        {
        +  "ttl": 0,
        +  "name": "string"
        +}
        +
        • required: true
        • parameter type: request body
        • data type: object
    • outcome
      • for super-admins or admin
        • Create and save AccessToken
        • return AccessToken created
      • forbidden otherwise

    Delete an Administrator's AccessTokens

    DELETE /administrators/{id}/access-tokens
    +

    This API allows a super-admin or admin to delete access tokens by Administrator id. An admin can only delete own records.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • Administrator id

        • required: true
        • parameter type: path
        • data type: string
      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
    • outcome

      • for super-admins or admin
        • delete all AccessToken under the Administrator matching the filter
        • return success count
      • forbidden otherwise
    • example

      to delete all access tokens created in year 2023 for administrator with id 1, run

      curl -X DELETE --header 'Accept: application/json' 'http://localhost:3000/api/administrators/1/access-tokens?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +
    + + + diff --git a/preview/docs/api-bounce/index.html b/preview/docs/api-bounce/index.html new file mode 100644 index 000000000..ff4aba6f4 --- /dev/null +++ b/preview/docs/api-bounce/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Bounce | NotifyBC + + + + +

    Bounce

    Bounce handling involves recording bounce messages into bounce records, which are implemented using this bounce API and model. Administrator can view bounce records in web console or through API explorer. Bounce record is for internal use and should be read-only under normal circumstances.

    Model Schema

    The API operates on following data model fields:

    NameAttributes

    channel

    name of the delivery channel. Valid values: email, sms.

    typestring
    requiredtrue

    userChannelId

    user's delivery channel id, for example, email address.
    typestring
    requiredtrue

    hardBounceCount

    number of hard bounces recorded so far

    typeinteger
    requiredtrue

    state

    bounce record state. Valid values: active, deleted.

    typestring
    requiredtrue

    bounceMessages

    array of recorded bounce messages. Each element is an object containing the date bounce message was received and the message itself.

    typearray
    requiredfalse

    latestNotificationStarted

    latest notification started date.

    typedate
    requiredfalse

    latestNotificationEnded

    latest notification ended date.

    typedate
    requiredfalse

    created

    date and time bounce record was created

    typedate
    auto-generatedtrue

    updated

    date and time of bounce record was last updated

    typedate
    auto-generatedtrue

    id

    config id

    typestring, format depends on db
    auto-generatedtrue
    + + + diff --git a/preview/docs/api-config/index.html b/preview/docs/api-config/index.html new file mode 100644 index 000000000..9140ad4ca --- /dev/null +++ b/preview/docs/api-config/index.html @@ -0,0 +1,66 @@ + + + + + + + + + Configuration | NotifyBC + + + + +

    Configuration

    The configuration API, accessible by only super-admin requests, is used to define dynamic configurations. Dynamic configuration is needed in situations like

    • RSA key pair generated automatically at boot time if not present
    • service-specific subscription confirmation request message template

    Model Schema

    The API operates on following configuration data model fields:

    NameAttributes

    id

    config id

    typestring, format depends on db
    auto-generatedtrue

    name

    config name

    typestring
    requiredtrue

    value

    config value.
    typeobject
    requiredtrue

    serviceName

    name of the service the config applicable to

    typestring
    requiredfalse

    Get Configurations

    GET /configurations
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • a filter containing properties where, fields, order, skip, and limit

        • parameter name: filter
        • required: false
        • parameter type: query
        • data type: object

        The filter can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"

        Regardless, the filter will have to be parsed into a JSON object conforming to

        {
        +    "where": {...},
        +    "fields": ...,
        +    "order": ...,
        +    "skip": ...,
        +    "limit": ...,
        +}
        +

        All properties are optional. The syntax for each property is documented, respectively

    • outcome

      For admin request, a list of config items matching the filter; forbidden for user request

    • example

      to retrieve configs created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/configurations?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
      +

      the value of the filter query parameter is URL-encoded stringified JSON object

      {
      +  "where": {
      +    "created": {
      +      "$gte": "2023-01-01",
      +      "$lt": "2024-01-01"
      +    }
      +  }
      +}
      +

    Create a Configuration

    POST /configurations
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • an object containing configuration data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated. The API explorer only created an empty object for field value but you should populate the child fields.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC performs following actions in sequence

      1. if it’s a user request, error is returned
      2. inputs are validated. For example, required fields without default values must be populated. If validation fails, error is returned
      3. if config item is notification with field value.rss populated, and if the field value.httpHost is missing, it is generated using this request's HTTP protocol , host name and port.
      4. item is saved to database

    Update a Configuration

    PATCH /configurations/{id}
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      Similar to POST except field update is always updated with current timestamp.

    Update Configurations

    PATCH /configurations
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
      • an object containing fields to be updated.

        • required: true
        • parameter type: body
        • data type: object
    • outcome

      Similar to POST except field update is always updated with current timestamp.

    • example

      to set serviceName to myService for all configs created in year 2023 , run

      curl -X PATCH --header 'Content-Type: application/json' 'http://localhost:3000/api/configurations?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D' -d @- << EOF
      +{
      +  "serviceName": "myService",
      +}
      +EOF
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +

    Delete a Configuration

    DELETE /configurations/{id}
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
    • outcome

      For admin request, delete the config item requested; forbidden for user request

    Replace a Configuration

    PUT /configurations/{id}
    +

    This API is intended to be only used by admin web console to modify a configuration.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • configuration id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • configuration data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      For admin requests, replace configuration identified by id with parameter data and save to database.

    + + + diff --git a/preview/docs/api-notification/index.html b/preview/docs/api-notification/index.html new file mode 100644 index 000000000..225cad1ec --- /dev/null +++ b/preview/docs/api-notification/index.html @@ -0,0 +1,92 @@ + + + + + + + + + Notification | NotifyBC + + + + +

    Notification

    The notification API encapsulates the backend workflow of staging and dispatching a message to targeted user after receiving the message from event source.

    Depending on whether an API call comes from user browser as a user request or from an authorized server application as an admin request, NotifyBC applies different permissions. Admin request allows full CRUD operations. An authenticated user request, on the other hand, are only allowed to get a list of in-app pull notifications targeted to the current user and changing the state of the notifications. An unauthenticated user request can not access any API.

    When a notification is created by the event source server application, the message is saved to database prior to responding to API caller. In addition, for push notification, the message is delivered immediately, i.e. the API call is synchronous. For in-app pull notification, the message, which by default is in state new, can be retrieved later on by browser user request. A user request can only get the list of in-app messages targeted to the current user. A user request can then change the message state to read or deleted depending on user action. A deleted message cannot be retrieved subsequently by user requests, but the state can be updated given the correct id.

    Deleted message is still kept in database.

    NotifyBC provides API for deleting a notification. For the purpose of auditing and recovery, this API only marks the state field as deleted rather than deleting the record from database.

    undo in-app notification deletion within a session

    Because "deleted" message is still kept in database, you can implement undo feature for in-app notification as long as the message id is retained prior to deletion within the current session. To undo, call update API to set desired state.

    In-app pull notification also supports message expiration by setting a date in field validTill. An expired message cannot be retrieved by user requests.

    A message, regardless of push or pull, can be unicast or broadcast. A unicast message is intended for an individual user whereas a broadcast message is intended for all confirmed subscribers of a service. A unicast message must have field userChannelId populated. The value of userChannelId is channel dependent. In the case of email for example, this would be user's email address. A broadcast message must set isBroadcast to true and leave userChannelId empty.

    Why field isBroadcast?

    Unicast and broadcast message can be distinguished by whether field userChannelId is empty or not alone. So why the extra field isBroadcast? This is in order to prevent inadvertent marking a unicast message broadcast by omitting userChannelId or populating it with empty value. The precaution is necessary because in-app notifications may contain personalized and confidential information.

    NotifyBC ensures the state of an in-app broadcast message is isolated by user, so that for example, a message read by one user is still new to another user. To achieve this, NotifyBC maintains two internal fields of array type - readBy and deletedBy. When a user request updates the state field of an in-app broadcast message to read or deleted, instead of altering the state field, NotifyBC appends the current user to readBy or deletedBy list. When user request retrieving in-app messages, the state field of the broadcast message in HTTP response is updated based on whether the user exists in field deletedBy and readBy. If existing in both fields, deletedBy takes precedence (the message therefore is not returned). The record in database, meanwhile, is unchanged. Neither field deletedBy nor readBy is visible to user request.

    Model Schema

    The API operates on following notification data model fields:

    NameAttributes

    id

    notification id

    typestring, format depends on db
    auto-generatedtrue

    serviceName

    name of the service

    typestring
    requiredtrue

    channel

    name of the delivery channel. Valid values: inApp, email, sms.

    typestring
    requiredtrue
    defaultinApp

    userChannelId

    user's delivery channel id, for example, email address. For unicast inApp notification, this is authenticated user id. When sending unicast push notification, either userChannelId or userId is required.

    typestring
    requiredfalse

    userId

    authenticated user id. When sending unicast push notification, either userChannelId or userId is required.

    typestring
    requiredfalse

    state

    state of notification. Valid values: new, read (inApp only), deleted (inApp only), sent (push only) or error. For inApp broadcast notification, if the user has read or deleted the message, the value of this field retrieved by admin request will still be new. The state for the user is tracked in fields readBy and deletedBy in such case. For user request, the value contains correct state.

    typestring
    requiredtrue
    defaultnew

    created

    date and time of creation

    typedate
    auto-generatedtrue

    updated

    date and time of last update

    typedate
    auto-generatedtrue

    isBroadcast

    whether it's a broadcast message. A broadcast message should omit userChannelId and userId, in addition to setting isBroadcast to true

    typeboolean
    requiredfalse
    defaultfalse

    skipSubscriptionConfirmationCheck

    When sending unicast push notification, whether or not to verify if the recipient has a confirmed subscription. This field allows subscription information be kept elsewhere and NotifyBC be used as a unicast push notification gateway only.

    typeboolean
    requiredfalse
    defaultfalse

    validTill

    expiration date-time of the message. Applicable to inApp notification only.

    typedate
    requiredfalse

    invalidBefore

    date-time in the future after which the notification can be dispatched.

    typedate
    requiredfalse

    message

    an object whose child fields are channel dependent:
    • for inApp, NotifyBC doesn't have any restriction as long as web application can handle the message. subject and body are common examples.
    • for email: from, subject, textBody, htmlBody
      • type: string
      • these are email template fields.
    • for sms: textBody
      • type: string
      • sms message template.
    Mail merge is performed on email and sms message templates.
    typeobject
    requiredtrue

    httpHost

    This field is used to replace token {http_host} in push notification message template during mail merge and overrides config httpHost.

    typestring
    requiredfalse
    default<http protocol, host and port of current request> for push notification

    asyncBroadcastPushNotification

    this field determines if the API call to create an immediate (i.e. not future-dated) broadcast push notification is asynchronous or not. If omitted, the API call is synchronous, i.e. the API call blocks until notifications to all subscribers have been dispatched. If set, valid values and corresponding behaviors are
    • true - async without callback
    • false - sync
    • a string containing callback url - async with a POST call to the supplied callback url upon completion
    When posting to a service with large number of subscribers, it is highly recommended to set the API call to asynchronous, i.e. setting the value to true or supplying a callback.
    typestring or boolean
    requiredfalse
    defaultfalse

    data

    the event that triggers the notification, for example, a RSS feed item when the notification is generated automatically by RSS cron job. Field data serves two purposes
    • to replace dynamic tokens in message template fields
    • to match against filter defined in subscription field broadcastPushNotificationFilter, if supplied, for broadcast push notifications to determine if the notification should be delivered to the subscriber
    typeobject
    requiredfalse

    broadcastPushNotificationSubscriptionFilter

    a string conforming to jmespath filter expressions syntax after the question mark (?). The filter is matched against the data field of the subscription. Examples of filter
    • simple
      province == 'BC'
    • calling jmespath's built-in functions
      contains(province,'B')
    • calling custom filter functions
      contains_ci(province,'b')
    • compound
      (contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria'
    All of above filters will match data object {"province": "BC", "city": "Victoria"}
    typestring
    requiredfalse

    readBy

    this is an internal field to track the list of users who have read an inApp broadcast message. It's not visible to a user request.

    typearray
    requiredfalse
    auto-generatedtrue

    deletedBy

    this is an internal field to track the list of users who have marked an inApp broadcast message as deleted. It's not visible to a user request.

    typearray
    requiredfalse
    auto-generatedtrue

    dispatch

    this is an internal field to track the broadcast push notification dispatch outcome. It consists of up to four arrays

    • failed - a list of objects containing subscription IDs and error of failed dispatching
    • successful - a list of strings containing subscription IDs of successful dispatching
    • skipped - a list of strings containing subscription IDs of skipped dispatching
    • candidates - a list of strings containing IDs of confirmed subscriptions to the service. Dispatching to a subscription is subject to filtering.
    typeobject
    requiredfalse
    auto-generatedtrue

    Get Notifications

    GET /notifications
    +
    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • a filter containing properties where, fields, order, skip, and limit

        • parameter name: filter
        • required: false
        • parameter type: query
        • data type: object

        The filter can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"

        Regardless, the filter will have to be parsed into a JSON object conforming to

        {
        +    "where": {...},
        +    "fields": ...,
        +    "order": ...,
        +    "skip": ...,
        +    "limit": ...,
        +}
        +

        All properties are optional. The syntax for each property is documented, respectively

    • outcome

      • for admin requests, returns unabridged array of notification data matching the filter
      • for authenticated user requests, in addition to filter, following constraints are imposed on the returned array
        • only inApp notifications
        • only non-deleted notifications. For broadcast notification, non-deleted means not marked by current user as deleted
        • only non-expired notifications
        • for unicast notifications, only the ones targeted to current user
        • if current user is in readBy, then the state is changed to read
        • the internal field readBy and deletedBy are removed
      • forbidden to anonymous user requests
    • example

      to retrieve notifications created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/notifications?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
      +

      the value of the filter query parameter is URL-encoded stringified JSON object

      {
      +  "where": {
      +    "created": {
      +      "$gte": "2023-01-01",
      +      "$lt": "2024-01-01"
      +    }
      +  }
      +}
      +

    Get Notification Count

    GET /notifications/count
    +
    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
    • outcome

      Validations rules are the same as GET /notifications. If passed, the output is a count of notifications matching the query

      {
      +  "count": <number>
      +}
      +
    • example

      to retrieve the count of notifications created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/notifications/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +

    Create/Send Notifications

    POST /notifications
    +
    • permissions required, one of

      • super admin
      • admin
    • inputs

      • an object containing notification data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated. The API explorer only created an empty object for field message but you should populate the child fields according to model schema
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC performs following actions in sequence

      1. if it's a user request, error is returned

      2. inputs are validated. If validation fails, error is returned. In particular, for unicast push notification, the recipient as identified by either userChannelId or userId must have a confirmed subscription if field skipSubscriptionConfirmationCheck is not set to true. If skipSubscriptionConfirmationCheck is set to true, then the subscription check is skipped, but in such case the request must contain userChannelId, not userId as subscription data is not queried to obtain userChannelId from userId.

      3. for push notification, if field httpHost is empty, it is populated based on request's http protocol and host.

      4. the notification request is saved to database

      5. if the notification is future-dated, then all subsequent request processing is skipped and response is sent back to user. Steps 7-11 below will be carried out later on by the cron job when the notification becomes current.

      6. if it's an async broadcast push notification, then response is sent back to user but steps 7-12 below is processed separately

      7. for unicast push notification, the message is dispatched to targeted user; for broadcast push notification, following actions are performed:

        1. number of confirmed subscriptions is retrieved

        2. the subscriptions are partitioned and processed concurrently as described in config section Broadcast Push Notification Task Concurrency

        3. when processing an individual subscription,

          1. if the subscription has filter rule defined in field broadcastPushNotificationFilter and notification contains field data, then the data is matched against the filter rule. Notification message is only dispatched if there is a match.
          2. if the notification has filter rule defined in field broadcastPushNotificationSubscriptionFilter and subscription contains field data, then the data is matched against the filter rule. Notification message is only dispatched if there is a match.

          If the subscription failed to pass any of the two filters, and if both guaranteedBroadcastPushDispatchProcessing and logSkippedBroadcastPushDispatches are true, the subscription id is logged to dispatch.skipped

        Regardless of unicast or broadcast, mail merge is performed on messages before dispatching.

      8. the state of push notification is updated to sent or error depending on sending status. For broadcast push notification, the dispatching could be failed only for a subset of users. In such case, the field dispatch.failed contains a list of objects of {userChannelId, subscriptionId, error} the message failed to deliver to, but the state will still be set to sent.

      9. For broadcast push notifications, if guaranteedBroadcastPushDispatchProcessing is true, then field dispatch.successful is populated with a list of subscriptionId of the successful dispatches.

      10. For push notifications, the bounce records of successful dispatches are updated

      11. the updated notification is saved back to database

      12. if it's an async broadcast push notification with a callback url, then the url is called with POST verb containing the notification with updated status as the request body

      13. for synchronous notification, the saved record is returned unless there is an error saving to database, in which case error is returned

    • example

      To send a unicast email push notification, copy and paste following json object to the data value box in API explorer, change email addresses as needed, and click Try it out! button:

      {
      +  "serviceName": "education",
      +  "userChannelId": "foo@bar.com",
      +  "skipSubscriptionConfirmationCheck": true,
      +  "message": {
      +    "from": "no_reply@bar.com",
      +    "subject": "test",
      +    "textBody": "This is a test"
      +  },
      +  "channel": "email"
      +}
      +

      As the result, foo@bar.com should receive an email notification even if the user is not a confirmed subscriber, and following json object is returned to caller upon sending the email successfully:

      {
      +  "serviceName": "education",
      +  "state": "sent",
      +  "userChannelId": "foo@bar.com",
      +  "skipSubscriptionConfirmationCheck": true,
      +  "message": {
      +    "from": "no_reply@bar.com",
      +    "subject": "test",
      +    "textBody": "This is a test"
      +  },
      +  "created": "2016-09-30T20:37:06.011Z",
      +  "updated": "2016-09-30T20:37:06.011Z",
      +  "channel": "email",
      +  "isBroadcast": false,
      +  "id": "57eeccf23427b61a4820775e"
      +}
      +

    Update a Notification

    PATCH /notifications/{id}
    +

    This API is mainly used for updating an inApp notification.

    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      • for user requests, NotifyBC performs following actions in sequence
        1. for unicast notification, if the notification is not targeted to current user, error is returned
        2. all fields except for state are discarded from the input
        3. for broadcast notification, current user id in appended to array readBy or deletedBy, depending on whether state is read or deleted, unless the user id is already in the array. The state field itself is then discarded
        4. the notification identified by id is merged with the updates and saved to database
        5. HTTP response code 204 is returned, unless there is error.
      • admin requests are allowed to update any field

    Delete a Notification

    This API is mainly used for marking an inApp notification deleted. It has the same effect as updating a notification with state set to deleted.

    DELETE /notifications/{id}
    +
    • permissions required, one of
      • super admin
      • admin
      • authenticated user
    • inputs
      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
    • outcome: same as the outcome of Update a Notification with state set to deleted.

    Replace a Notification

    PUT /notifications/{id}
    +

    This API is intended to be only used by admin web console to modify a notification in new state. Notifications in such state are typically future-dated or of channel in-app.

    • permissions required, one of

      • super admin
      • admin
    • inputs

      • notification id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • notification data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC process the request same way as Create/Send Notifications except that notification data is saved with id supplied in the parameter, replacing existing one.

    + + + diff --git a/preview/docs/api-overview/index.html b/preview/docs/api-overview/index.html new file mode 100644 index 000000000..33948d30a --- /dev/null +++ b/preview/docs/api-overview/index.html @@ -0,0 +1,33 @@ + + + + + + + + + API Overview | NotifyBC + + + + +

    API Overview

    NotifyBC's core function is implemented by two models - subscription and notification. Other models - configuration, administrator and bounces etc, are for administrative purposes. A model determines the underlying database schema and the API. The APIs displayed in the web console (by default http://localhost:3000) and API explorer are also grouped by models. Click on a model in API explorer, say notification, to explore the operations on that model. Model specific APIs are available here:

    + + + diff --git a/preview/docs/api-subscription/index.html b/preview/docs/api-subscription/index.html new file mode 100644 index 000000000..da5e40af6 --- /dev/null +++ b/preview/docs/api-subscription/index.html @@ -0,0 +1,115 @@ + + + + + + + + + Subscription | NotifyBC + + + + +

    Subscription

    The subscription API encapsulates the backend workflow of user subscription and un-subscription of push notification service. Depending on whether a API call comes from user browser as a user request or from an authorized server as an admin request, NotifyBC applies different validation rules. For user requests, the notification channel entered by user is unconfirmed. A confirmation code will be associated with this request. The confirmation code can be created in one of two ways:

    • by NotifyBC based on channel dependent subscription.confirmationRequest.<channel>.confirmationCodeRegex config.
    • by a trusted third party. This trusted third party encrypts the confirmation code using the public RSA key of the NotifyBC instance (see more about RSA Key Config) and pass the encrypted confirmation code to NotifyBC via user browser in the same subscription request. NotifyBC then decrypts to obtain the confirmation code. This method allows user subscribe to multiple notification services provided by NotifyBC instances in different trust domains (i.e. service providers) and only have to confirm the subscription channel once during one browser session. In such case only one NotifyBC instance should be chosen to deliver confirmation request to user.

    Equipped with the confirmation code and a message template, NotifyBC can now send out confirmation request to unconfirmed subscription channel. At a minimum this confirmation request should contain the confirmation code. When user receives the message, he/she echos the confirmation code back to a NotifyBC provided API to verify against saved record. If match, the state of the subscription request is changed to confirmed.

    For admin requests, NotifyBC can still perform the above confirmation process. But admin request has full CRUD privilege, including set the subscription state to confirmed, bypassing the confirmation process.

    The workflow of user subscribing to notification services offered by a single service provider is illustrated by sequence diagram below. In this case, the confirmation code is generated by NotifyBC. single service provider subscription

    In the case user subscribing to notifications offered by different service providers in separate trust domains, the confirmation code is generated by a third-party server app trusted by all NotifyBC instances. Following sequence diagram shows the workflow. The diagram indicates NotifyBC API Server 2 is chosen to send confirmation request.

    multi service provider subscription

    Model Schema

    The API operates on following subscription data model fields:

    NameAttributes

    serviceName

    name of the service. Avoid prefixing the name with underscore (_), or it may conflict with internal implementation.

    typestring
    requiredtrue

    channel

    name of the delivery channel. Valid values: email and sms. Notice inApp is invalid as in-app notification doesn't need subscription.

    typestring
    requiredtrue
    defaultemail

    userChannelId

    user's delivery channel id, for example, email address

    typestring
    requiredtrue

    id

    subscription id

    typestring, format depends on db
    requiredfalse
    auto-generatedtrue

    state

    state of subscription. Valid values: unconfirmed, confirmed, deleted

    typestring
    requiredfalse
    defaultunconfirmed

    userId

    user id. Auto-populated for authenticated user requests.

    typestring
    requiredfalse

    created

    date and time of creation

    typedate
    requiredfalse
    auto-generatedtrue

    updated

    date and time of last update

    typedate
    requiredfalse
    auto-generatedtrue

    confirmationRequest

    an object containing these child fields
    • confirmationCodeRegex
      • type: string
      • regular expression used to generate confirmation code
    • confirmationCodeEncrypted
      • type: string
      • encrypted confirmation code
    • sendRequest
      • type: boolean
      • whether to send confirmation request
    • from, subject, textBody, htmlBody
      • type: string
      • these are email template fields used for sending email confirmation request. If confirmationRequest.sendRequest is true and channel is email, then these fields should be supplied in order to send confirmation email.
    typeobject
    requiredtrue for user request with encrypted confirmation code; false otherwise

    broadcastPushNotificationFilter

    a string conforming to jmespath filter expressions syntax after the question mark (?). The filter is matched against the data field of the subscription. Examples of filter
    • simple
      province == 'BC'
    • calling jmespath's built-in functions
      contains(province,'B')
    • calling custom filter functions
      contains_ci(province,'b')
    • compound
      (contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria'
    All of above filters will match data object {"province": "BC", "city": "Victoria"}
    typestring
    requiredfalse

    data

    An object used by

    data object can only be populated by non-anonymous requests.

    typeobject
    requiredfalse

    unsubscriptionCode

    generated randomly according to RegEx config anonymousUnsubscription.code.regex during anonymous subscription if config anonymousUnsubscription.code.required is set to true

    typestring
    requiredfalse
    auto-generatedtrue

    unsubscribedAdditionalServices

    generated if parameter additionalServices is supplied in unsubscription request. Contains 2 sub-fields: ids and names, each being a list identifying the additional unsubscribed subscriptions.

    typeobject
    requiredfalse
    auto-generatedtrue

    Get Subscriptions

    GET /subscriptions
    +
    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • a filter containing properties where, fields, order, skip, and limit

        • parameter name: filter
        • required: false
        • parameter type: query
        • data type: object

        The filter can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"

        Regardless, the filter will have to be parsed into a JSON object conforming to

        {
        +    "where": {...},
        +    "fields": ...,
        +    "order": ...,
        +    "skip": ...,
        +    "limit": ...,
        +}
        +

        All properties are optional. The syntax for each property is documented, respectively

    • outcome

      • for admin requests, returns unabridged array of subscription data matching the filter
      • for authenticated user requests, in addition to filter, following constraints are imposed on the returned array
        • only non-deleted subscriptions
        • only subscriptions created by the user
        • the confirmationRequest field is removed.
      • forbidden for anonymous user requests
    • example

      to retrieve subscriptions created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/subscriptions?filter=%7B%22where%22%3A%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D%7D'
      +

      the value of the filter query parameter is URL-encoded stringified JSON object

      {
      +  "where": {
      +    "created": {
      +      "$gte": "2023-01-01",
      +      "$lt": "2024-01-01"
      +    }
      +  }
      +}
      +

    Get Subscription Count

    GET /subscriptions/count
    +
    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • a where query parameter with value conforming to MongoDB Query Documentsopen in new window

        • parameter name: where
        • required: false
        • parameter type: query
        • data type: object

        The value can be expressed as either

        1. URL-encoded stringified JSON object (see example below); or
        2. in the format supported by qsopen in new window, for example ?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"
    • outcome

      Validations rules are the same as GET /subscriptions. If passed, the output is a count of subscriptions matching the query

      {
      +  "count": <number>
      +}
      +
    • example

      to retrieve the count of subscriptions created in year 2023, run

      curl -X GET --header 'Accept: application/json' 'http://localhost:3000/api/subscriptions/count?where=%7B%22created%22%3A%7B%22%24gte%22%3A%222023-01-01%22%2C%22%24lt%22%3A%222024-01-01%22%7D%7D'
      +

      the value of the where query parameter is URL-encoded stringified JSON object

      {
      +  "created": {
      +    "$gte": "2023-01-01",
      +    "$lt": "2024-01-01"
      +  }
      +}
      +

    Create a Subscription

    POST /subscriptions
    +
    • inputs

      • an object containing subscription data model fields. At a minimum all required fields that don't have a default value must be supplied. Id field should be omitted since it's auto-generated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC performs following actions in sequence

      1. inputs are validated. If validation fails, error is returned.

      2. for user requests, the state field is forced to unconfirmed

      3. for authenticated user request, userId field is populated with authenticated userId

      4. otherwise, unsubscriptionCode is generated if config subscription.anonymousUnsubscription.code.required is true, unless if the request is made by admin and the field is already populated

      5. if confirmationRequest.confirmationCodeEncrypted is populated, a confirmation code is generated by decrypting this field using private RSA key, then put decrypted confirmation code to field confirmationRequest.confirmationCode

      6. otherwise, for user requests and for admin requests missing message template, the message template is set to configured value. Then, if confirmationRequest.confirmationCodeRegex is populated, a confirmation code is generated conforming to regex and put to field confirmationRequest.confirmationCode

      7. the subscription request is saved to database.

      8. if confirmationRequest.sendRequest is true, then a message is sent to userChannelId. The message template is determined by

        1. if detectDuplicatedSubscription is true and there is already a confirmed subscription to the same serviceName, channel and userChannelId, then message is sent using duplicatedSubscriptionNotification as template;
        2. otherwise, a confirmation request is sent to using the template fields in confirmationRequest.

        Mail merge is performed on the template regardless.

      9. The subscription data, including auto-generated id, is returned as response unless there is error when sending confirmation request or saving to database. For user request, some fields containing sensitive information such as confirmationRequest are removed prior to sending the response.

    • examples

      1. To subscribe a user to service education, copy and paste following json object to the data value box in API explorer, change email addresses as needed, and click Try it out! button:
      {
      +  "serviceName": "education",
      +  "channel": "email",
      +  "userChannelId": "foo@bar.com"
      +}
      +

      As a result, foo@bar.com should receive an email confirmation request, and following json object is returned to caller upon sending the email successfully for admin request:

      {
      +  "serviceName": "education",
      +  "channel": "email",
      +  "userChannelId": "foo@bar.com",
      +  "state": "unconfirmed",
      +  "confirmationRequest": {
      +    "confirmationCodeRegex": "\\d{5}",
      +    "sendRequest": true,
      +    "from": "no_reply@bar.com",
      +    "subject": "confirmation",
      +    "textBody": "Enter {confirmation_code} on screen",
      +    "confirmationCode": "45304"
      +  },
      +  "created": "2016-10-03T17:35:40.202Z",
      +  "updated": "2016-10-03T17:35:40.202Z",
      +  "id": "57f296ec7eead50554c61de7"
      +}
      +

      For non-admin request, the field confirmationRequest is removed from response, and field userId is populated if request is authenticated:

      {
      +  "serviceName": "education",
      +  "channel": "email",
      +  "userChannelId": "foo@bar.com",
      +  "state": "unconfirmed",
      +  "userId": "<user_id>",
      +  "created": "2016-10-03T18:17:09.778Z",
      +  "updated": "2016-10-03T18:17:09.778Z",
      +  "id": "57f2a0a5b1aa0e2d5009eced"
      +}
      +
      1. To subscribe a user to service education with RSA public key encrypted confirmation code supplied, POST following request

        {
        +  "serviceName": "education",
        +  "channel": "email",
        +  "userChannelId": "foo@bar.com",
        +  "confirmationRequest": {
        +    "confirmationCodeEncrypted": "<encrypted-confirmation-code>",
        +    "sendRequest": true,
        +    "from": "no_reply@bar.com",
        +    "subject": "confirmation",
        +    "textBody": "Enter {confirmation_code} on screen"
        +  }
        +}
        +

        As a result, NotifyBC will decrypt the confirmation code using the private RSA key, replace placeholder {confirmation_code} in the email template with the confirmation code, and send confirmation request to foo@bar.com.

    Verify a Subscription

    GET /subscriptions/{id}/verify
    +
    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • confirmation code
        • parameter name: confirmationCode
        • required: true
        • parameter type: query
        • data type: string
      • whether or not replacing existing subscriptions
        • parameter name: replace
        • required: false
        • parameter type: query
        • data type: boolean
    • outcome

      NotifyBC performs following actions in sequence

      1. the subscription identified by id is retrieved
      2. for user request, the userId of the subscription is checked against current request user, if not match, error is returned; otherwise
      3. input parameter confirmationCode is checked against confirmationRequest.confirmationCode. If not match, error is returned; otherwise
      4. if input parameter replace is supplied and set to true, then existing confirmed subscriptions from the same serviceName, channel and userChannelId are deleted. No unsubscription acknowledgement notification is sent
      5. state is set to confirmed
      6. the subscription is saved back to database
      7. displays acknowledgement message according to configuration
    • example

      to verify a subscription with id abc, confirmation code 12345, and delete existing confirmed subscriptions once verified, run

      curl 'http://localhost:3000/api/subscriptions/abc/verify?confirmationCode=12345&replace=true'
      +

    Update a Subscription

    PATCH /subscriptions/{id}
    +

    This API is used by authenticated user to change user channel id (such as email address) and resend confirmation code.

    • permissions required, one of

      • super admin
      • admin
      • authenticated user
    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • an object containing fields to be updated.
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome

      NotifyBC processes the request similarly as creating a subscription except during input validation it imposes following extra constraints to user request

      • only fields userChannelId, state and confirmationRequest can be updated
      • when changing userChannelId, confirmationRequest must also be supplied
      • if userChannelId is different from the saved record, state is forced to unconfirmed.

    Delete a Subscription (unsubscribing)

    DELETE /subscriptions/{id}?unsubscriptionCode={unsubscriptionCode}&additionalServices[]={additionalServices}&userChannelId={userChannelId}
    +or
    +GET /subscriptions/{id}/unsubscribe?unsubscriptionCode={unsubscriptionCode}&additionalServices[]={additionalServices}&userChannelId={userChannelId}
    +
    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • unsubscription code for anonymous request
        • parameter name: unsubscriptionCode
        • required: false
        • parameter type: query
        • data type: string
      • additional service names to unsubscribe
        • parameter name: additionalServices
        • required: false
        • parameter type: query
        • data type: array of strings. If there is only one item and its value is _all, then all services the user subscribed on this NotifyBC instance are included. Supply multiple items by repeating this query parameter.
      • user channel id for extended validation
        • parameter name: userChannelId
        • required: false
        • parameter type: query
        • data type: string
    • outcome

      NotifyBC performs following actions in sequence

      1. the subscription identified by id is retrieved
      2. for user request,
      • if request is authenticated, the userId of the subscription is checked against current request user, if not match, request is rejected
      • if request is anonymous, and server is configured to require unsubscription code, the input unsubscription code is matched against the unsubscriptionCode field. Request is rejected if not match. In addition, if input parameter userChannelId is populated but doesn't match, request is rejected
      1. if the subscription state is not confirmed, request is rejected
      2. if additionalServices is populated, database is queried to retrieve the serviceName and id fields of the additional subscriptions
      3. the field state is set to deleted for the subscription identified by id as well as additional subscriptions retrieved in previous step
      4. if additionalServices is not empty, the service names and ids of the additional subscriptions are added to field unsubscribedAdditionalServices of the subscription identified by id to allow bulk undo unsubscription later on
      5. for anonymous unsubscription, an acknowledgement notification is sent to user if configured so
      6. returns
      • for anonymous request, either the message or redirect as configured in anonymousUnsubscription.acknowledgements.onScreen
      • for authenticated user or admin requests, number of records affected or error message if occurred.
    • examples

      1. To allow an anonymous subscriber to unsubscribe single subscription, provide url token {unsubscription_url} in notification messages. When sending notification, mail merge is performed on the token resolving to the GET API url and parameters.
      2. To allow an anonymous subscriber to unsubscribe all subscriptions, provide url token {unsubscription_all_url} in notification messages.

    Un-deleting a Subscription

    GET /subscriptions/{id}/unsubscribe/undo
    +

    This API allows an anonymous subscriber to undo an unsubscription.

    • inputs

      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • unsubscription code
        • parameter name: unsubscriptionCode
        • required: false
        • parameter type: query
        • data type: string
    • outcome

      NotifyBC performs following actions in sequence

      1. the subscription identified by id is retrieved
      2. for user request,
      • if request is anonymous, and server is configured to require unsubscription code, the input unsubscription code is matched against the unsubscriptionCode field. Request is rejected if not match
      • if request is authenticated, request is rejected
      • if the subscription state is not deleted, request is rejected
      1. the field state is set to confirmed for the subscription identified by id as well as additional subscriptions identified in field unsubscribedAdditionalServices, if populated
      2. field unsubscribedAdditionalServices is removed if populated
      3. returns either the message or redirect as configured in anonymousUndoUnsubscription
    • example

      To allow an anonymous subscriber to undo unsubscription, provide link token {unsubscription_reversion_url} in unsubscription acknowledgement notification, which is by default set. When sending notification, mail merge is performed on this token resolving to the API url and parameters.

    Get all services with confirmed subscribers

    GET /subscriptions/services
    +

    This API is designed to facilitate implementing autocomplete for admin web console.

    • permissions required, one of
      • super admin
      • admin
    • inputs - none
    • outcome
      • for admin requests, returns an array of unique service names with confirmed subscribers
      • forbidden for non-admin requests

    Replace a Subscription

    PUT /subscriptions/{id}
    +

    This API is intended to be only used by admin web console to modify a subscription without triggering any confirmation or acknowledgement notification.

    • permissions required, one of
      • super admin
      • admin
    • permissions required, one of
      • super admin
      • admin
      • authenticated user
    • inputs
      • subscription id
        • parameter name: id
        • required: true
        • parameter type: path
        • data type: string
      • subscription data
        • parameter name: data
        • required: true
        • parameter type: body
        • data type: object
    • outcome
      • for admin requests, replace subscription identified by id with parameter data and save to database. No notification is sent.
      • forbidden for non-admin requests
    + + + diff --git a/preview/docs/benchmarks/index.html b/preview/docs/benchmarks/index.html new file mode 100644 index 000000000..f35f1830b --- /dev/null +++ b/preview/docs/benchmarks/index.html @@ -0,0 +1,79 @@ + + + + + + + + + Benchmarks | NotifyBC + + + + +

    Benchmarks

    tl;dr

    A NotifyBC server node can deliver 1 million emails in as little as 1 hour to a SMTP server node. SMTP server node's disk I/O is the bottleneck in such case. Throughput can be improved through horizontal scaling.

    When NotifyBC is used to deliver broadcast push notifications to a large number of subscribers, probably the most important benchmark is throughput. The benchmark is especially critical if a latency cap is desired. To facilitate capacity planning, load testing on the email channel has been conducted. The test environment, procedure, results and performance tuning advices are provided hereafter.

    Environment

    Hardware

    Two computers, connected by 1Gbps LAN, are used to host

    • NotifyBC
      • Mac Mini Late 2012 model
      • Intel core i7-3615QM
      • 16GB RAM
      • 2TB HDD
    • SMTP and mail delivery
      • Lenovo ThinkCentre M Series 2015 model
      • Intel core i5-3470
      • 8GB RAM
      • 256GB SSD

    Software Stack

    The test was performed in August 2017. Unless otherwise specified, the versions of all other software were reasonably up-to-date at the time of testing.

    • NotifyBC

      • MacOS Sierra Version 10.12.6
      • Virtualbox VM with 8vCPU, 10GB RAM, created using miniShift v1.3.1+f4900b07
      • OpenShift 1.5.1+7b451fc with metrics
      • default NotifyBC OpenShift installation, which contains following relevant pods
        • 1 mongodb pod with 1 core, 1GiB RAM limit
        • a variable number of Node.js app pods each with 1 core, 1GiB RAM limit. The number varies by test runs as indicated in result.
    • SMTP and mail delivery

      • Windows 7 host
      • Virtualbox VM with 4 vCPU, 3.5GB RAM, running Windows Server 2012
      • added SMTP Server feature
      • in SMTP Server properties dialog box, uncheck all of following boxes in Messages tab
        • Limit message size to (KB)
        • Limit session size to (KB)
        • Limit number of messages per connection to
        • Limit number of recipients per message to

    Procedure

    1. update or create file /src/config.local.js through configMap. Add sections for SMTP server and a custom filter function

      var _ = require('lodash');
      +module.exports = {
      +  smtp: {
      +    host: '<smtp-vm-ip-or-hostname>',
      +    secure: false,
      +    port: 25,
      +    pool: true,
      +    direct: false,
      +    maxMessages: Infinity,
      +    maxConnections: 50,
      +  },
      +  notification: {
      +    broadcastCustomFilterFunctions: {
      +      /*jshint camelcase: false */
      +      contains_ci: {
      +        _func: function (resolvedArgs) {
      +          if (!resolvedArgs[0] || !resolvedArgs[1]) {
      +            return false;
      +          }
      +          return (
      +            _.toLower(resolvedArgs[0]).indexOf(_.toLower(resolvedArgs[1])) >=
      +            0
      +          );
      +        },
      +        _signature: [
      +          {
      +            types: [2],
      +          },
      +          {
      +            types: [2],
      +          },
      +        ],
      +      },
      +    },
      +  },
      +};
      +
    2. create a number of subscriptions in bulk using script bulk-post-subs.jsopen in new window. To load test different email volumes, you can create bulk subscriptions in different services. For example, generate 10 subscriptions under service named load10; 1,000,000 subscriptions under service load1000000 etc. bulk-post-subs.js takes userChannelId and other optional parameters

      $ node dist/utils/load-testing/bulk-post-subs.js -h
      +Usage: node bulk-post-subs.js [Options] <userChannelId>
      +[Options]:
      +-a, --api-url-prefix=<string>                      api url prefix. default to http://localhost:3000/api
      +-c, --channel=<string>                             channel. default to email
      +-s, --service-name=<string>                        service name. default to load
      +-n, --number-of-subscribers=<int>                  number of subscribers. positive integer. default to 1000
      +-f, --broadcast-push-notification-filter=<string>  broadcast push notification filter. default to "contains_ci(title,'vancouver') || contains_ci(title,'victoria')"
      +-h, --help                                         display this help
      +

      The generated subscriptions contain a filter, hence all load testing results below included time spent on filtering.

    3. launch load testing using script curl-ntf.shopen in new window, which takes following optional parameters

      dist/utils/load-testing/curl-ntf.sh <apiUrlPrefix> <serviceName> <senderEmail>
      +

      The script will print start time and the time taken to dispatch the notification.

    Results

    email counttime taken (min)throughput (#/min)app pod countnotes on bottleneck
    1,000,00071.513,9861app pod cpu capped
    100,0005.817,2412smtp vm disk queue length hits 1 frequently
    1,000,0005717,5442smtp vm disk queue length hits 1 frequently
    1,000,00057.817,3013smtp vm disk queue length hits 1 frequently

    Test runs using other software or configurations described below have also been conducted. Because throughput is significantly lower, results are not shown

    • using Linux sendmail SMTP. The throughput of a 4-vCPU Linux VM is about the same as a 1-vCPU Windows SMTP server. Bottleneck in such case is the CPU of SMTP server.
    • Reducing NotifyBC app pod's resource limit to 100 millicore CPU and 512MiB RAM. Even when scaled up pod count to 15, throughput is still about 1/3 of a 1-core pod.

    Here is a sample email saved onto the mail drop folder of SMTP server.

    Comparison to Other Benchmarks

    According to Baseline Performance for SMTPopen in new window published on Microsoft Technet in 2005, Windows SMTP server has a max throughput of 142 emails/s. However this NotifyBC load test yields a max throughput of 292 emails/s. The discrepancy may be attributed to following factors

    1. Email size in Microsoft's load test is 50k, as opposed to 1k used in this test
    2. SSD storage is used in this test. It is unlikely the test conducted in 2005 used SSD.

    Advices

    • Avoid using default direct mode in production. Instead use SMTP server. Direct mode doesn't support connection pooling, resulting in port depletion quickly.
    • Enable SMTP poolingopen in new window.
    • Set smtp config maxConnections to a number big enough as long as SMTP server can handle. Test found for Windows SMTP server 50 is a suitable number, beyond which performance gain is insignificant.
    • Set smtp config maxMessages to maximum possible number allowed by your SMTP server, or Infinity if SMTP server imposes no such constraint
    • Avoid setting CPU resource limit too low for NotifyBC app pods.
    • If you have control over the SMTP server,
      • use SSD for its storage
      • create a load balanced cluster if possible, since SMTP server is more likely to be the bottleneck.
    + + + diff --git a/preview/docs/bulk-import/index.html b/preview/docs/bulk-import/index.html new file mode 100644 index 000000000..d7b6f2fa0 --- /dev/null +++ b/preview/docs/bulk-import/index.html @@ -0,0 +1,44 @@ + + + + + + + + + Bulk Import | NotifyBC + + + + +

    Bulk Import

    To migrate subscriptions from other notification systems, you can use mongoimportopen in new window. NotifyBC also provides a utility script to bulk import subscription data from a .csv file. To use the utility, you need

    • Software installed
      • Node.js
      • Git
    • Admin Access to a NotifyBC instance by adding your client ip to the Admin IP List
    • a csv file with header row matching subscription model schema. A sample csv file is providedopen in new window. Compound fields (of object type) should be dot-flattened as shown in the sample for field confirmationRequest.sendRequest

    To run the utility

    git clone https://github.com/bcgov/NotifyBC.git
    +cd NotifyBC
    +npm i && npm run build
    +node dist/utils/bulk-import/subscription.js -a <api-url-prefix> -c <concurrency> <csv-file-path>
    +

    Here <csv-file-path> is the path to csv file and <api-url-prefix> is the NotifyBC api url prefix , default to http://localhost:3000/api.

    The script parses the csv file and generates a HTTP post request for each row. The concurrency of HTTP request is controlled by option -c which is default to 10 if omitted. A successful run should output the number of rows imported without any error message

    success row count = ***
    +

    Field Parsers

    The utility script takes care of type conversion for built-in fields. If you need to import proprietary fields, by default the fields are imported as strings. To import non-string fields or manipulating json output, you need to define custom parsersopen in new window in file src/utils/bulk-import/subscription.tsopen in new window. For example, to parse myCustomIntegerField to integer, add in the colParser object

      colParser: {
    +    ...
    +    , myCustomIntegerField: (item, head, resultRow, row, colIdx) => {
    +      return parseInt(item)
    +    }
    +  }
    +
    + + + diff --git a/preview/docs/conduct/index.html b/preview/docs/conduct/index.html new file mode 100644 index 000000000..a8b25d648 --- /dev/null +++ b/preview/docs/conduct/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Code of Conduct | NotifyBC + + + + +

    Code of Conduct

    As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.

    We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality.

    Examples of unacceptable behavior by participants include:

    • The use of sexualized language or imagery
    • Personal attacks
    • Trolling or insulting/derogatory comments
    • Public or private harassment
    • Publishing other's private information, such as physical or electronic addresses, without explicit permission
    • Other unethical or unprofessional conduct

    Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.

    By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team.

    This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community.

    Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting a project maintainer. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident.

    This Code of Conduct is adapted from the Contributor Covenantopen in new window, version 1.3.0, available at http://contributor-covenant.org/version/1/3/0/open in new window

    + + + diff --git a/preview/docs/config-adminIpList/index.html b/preview/docs/config-adminIpList/index.html new file mode 100644 index 000000000..36370f189 --- /dev/null +++ b/preview/docs/config-adminIpList/index.html @@ -0,0 +1,39 @@ + + + + + + + + + Admin IP List | NotifyBC + + + + +

    Admin IP List

    By design, NotifyBC classifies incoming requests into four types. For a request to be classified as super-admin, the request's source ip must be in admin ip list. By default, the list contains localhost only as defined by adminIps in /src/config.ts

    module.exports = {
    +  adminIps: ['127.0.0.1'],
    +};
    +

    to modify, create config object adminIps with updated list in file /src/config.local.js instead. For example, to add ip range 192.168.0.0/24 to the list

    module.exports = {
    +  adminIps: ['127.0.0.1', '192.168.0.0/24'],
    +};
    +

    It should be noted that NotifyBC may generate http requests sending to itself. These http requests are expected to be admin requests. If you have created an app cluster such as in Kubernetes, you should add the cluster ip range to adminIps. In Kubernetes, this ip range is a private ip range. For example, in BCGov's OpenShift cluster OCP4, the ip range starts with octet 10.

    + + + diff --git a/preview/docs/config-certificates/index.html b/preview/docs/config-certificates/index.html new file mode 100644 index 000000000..ab48001af --- /dev/null +++ b/preview/docs/config-certificates/index.html @@ -0,0 +1,48 @@ + + + + + + + + + TLS Certificates | NotifyBC + + + + +

    TLS Certificates

    NotifyBC supports HTTPS TLS to achieve end-to-end encryption. In addition, both server and client can be authenticated using certificates.

    To enable HTTPS for server authentication only, you need to create two files

    • server/certs/key.pem - a PEM encoded private key
    • server/certs/cert.pem - a PEM encoded X.509 certificate chain

    Use ConfigMaps on Kubernetes

    Create key.pem and cert.pem as items in ConfigMap notify-bc, then mount the items under /home/node/app/server/certs similar to how config.local.js and middleware.local.js are implemented.

    For self-signed certificate, run

    openssl req -x509 -newkey rsa:4096 -keyout server/certs/key.pem -out server/certs/cert.pem -nodes -days 365 -subj "/CN=NotifyBC"
    +

    to generate both files in one shot.

    Caution about self-signed cert

    Self-signed cert is intended to be used in non-production environments only to authenticate server. In such environments to allow NotifyBC connecting to itself, environment variable NODE_TLS_REJECT_UNAUTHORIZED must be set to 0.

    To create a CSR from the private key generated above, run

    openssl req -new -key server/certs/key.pem -out server/certs/csr.pem
    +

    Then bring your CSR to your CA to sign. Replace server/certs/cert.pem with the cert signed by CA. If your CA also supplied intermediate certificate in PEM encoded format, say in a file called intermediate.pem, append all of the content of intermediate.pem to file server/certs/cert.pem.

    Make a copy of self-signed server/certs/cert.pem

    If you want to enable client certificate authentication documented below, make sure to copy self-signed server/certs/cert.pem to server/certs/ca.pem before replacing the file with the cert signed by CA. You need the self-signed server/certs/cert.pem to sign client CSR.

    In case you created server/certs/key.pem and server/certs/cert.pem but don't want to enable HTTPS, create following config in src/config.local.js

    module.exports = {
    +  tls: {
    +    enabled: false,
    +  },
    +};
    +

    Update URL configs after enabling HTTPS

    Make sure to update the protocol of following URL configs after enabling HTTPS

    Client certificate authentication

    After enabling HTTPS, you can further configure such that a client request can be authenticated using client certificate. To do so, copy self-signed server/certs/cert.pem to server/certs/ca.pem. You will use your server key to sign client certificate CSR, and advertise server/certs/ca.pem as acceptable CAs during TLS handshake.

    Assuming a client's CSR file is named myClientApp_csr.pem, to sign the CSR

    openssl x509 -req -in myClientApp_csr.pem -CA server/certs/ca.pem -CAkey server/certs/key.pem -out myClientApp_cert.pem -set_serial 01 -days 365
    +

    Then give myClientApp_cert.pem to the client. How a client app supplies the client certificate when making a request to NotifyBC varies by client type. Usually the client first needs to bundle the signed client cert and client key into PKCS#12 format

    openssl pkcs12 -export -clcerts -in myClientApp_cert.pem -inkey myClientApp_key.pem -out myClientApp.p12
    +

    To use myClientApp.p12, for cURL,

    curl --insecure --cert myClientApp.p12 --cert-type p12 https://localhost:3000/api/administrators/whoami
    +

    For browsers, check browser's instruction how to import myClientApp.p12. When browser accessing NotifyBC API endpoints such as https://localhost:3000/api/administrators/whoami, the browser will prompt to choose from a list certificates that are signed by the server certificate.

    In case you created server/certs/ca.pem but don't want to enable client certificate authentication, create following config in src/config.local.js

    module.exports = {
    +  tls: {
    +    clientCertificateEnabled: false,
    +  },
    +};
    +

    TLS termination has to be passthrough

    For client certification authentication to work, TLS termination of all reverse proxies has to be set to passthrough rather than offload and reload. This means, for example, when NotifyBC is hosted on OpenShift, router tls terminationopen in new window has to be changed from edge to passthrough.

    NotifyBC internal request does not use client certificate

    Requests sent by a NotifyBC node back to the app cluster use admin ip list authentication.

    + + + diff --git a/preview/docs/config-cronJobs/index.html b/preview/docs/config-cronJobs/index.html new file mode 100644 index 000000000..adb33e1c8 --- /dev/null +++ b/preview/docs/config-cronJobs/index.html @@ -0,0 +1,95 @@ + + + + + + + + + Cron Jobs | NotifyBC + + + + +

    Cron Jobs

    NotifyBC runs several cron jobs described below. These jobs are controlled by sub-properties defined in config object cron. To change config, create the object and properties in file /src/config.local.js.

    By default cron jobs are enabled. In a multi-node deployment, cron jobs should only run on the master node to ensure single execution.

    All cron jobs have a property named timeSpec with the value of a space separated fields conforming to unix crontab formatopen in new window with an optional left-most seconds field. See allowed rangesopen in new window of each field.

    Purge Data

    This cron job purges old notifications, subscriptions and notification bounces. The default frequency of cron job and retention policy are defined by cron.purgeData config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    purgeData: {
    +      // daily at 1am
    +      timeSpec: '0 0 1 * * *',
    +      pushNotificationRetentionDays: 30,
    +      expiredInAppNotificationRetentionDays: 30,
    +      nonConfirmedSubscriptionRetentionDays: 30,
    +      deletedBounceRetentionDays: 30,
    +      expiredAccessTokenRetentionDays: 30,
    +      defaultRetentionDays: 30,
    +    },
    +  },
    +};
    +

    where

    • pushNotificationRetentionDays: the retention days of push notifications
    • expiredInAppNotificationRetentionDays: the retention days of expired inApp notifications
    • nonConfirmedSubscriptionRetentionDays: the retention days of non-confirmed subscriptions, i.e. all unconfirmed and deleted subscriptions
    • deletedBounceRetentionDays: the retention days of deleted notification bounces
    • expiredAccessTokenRetentionDays: the retention days of expired access tokens
    • defaultRetentionDays: if any of the above retention day config item is omitted, default retention days is used as fall back.

    To change a config item, set the config item in file /src/config.local.js. For example, to run cron jobs at 2am daily, add following object to /src/config.local.js

    module.exports = {
    +  cron: {
    +    purgeData: {
    +      timeSpec: '0 0 2 * * *',
    +    },
    +  },
    +};
    +

    Dispatch Live Notifications

    This cron job sends out future-dated notifications when the notification becomes current. The default config is defined by cron.dispatchLiveNotifications config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    dispatchLiveNotifications: {
    +      // minutely
    +      timeSpec: '0 * * * * *',
    +    },
    +  },
    +};
    +

    Check Rss Config Updates

    This cron job monitors RSS feed notification dynamic config items. If a config item is created, updated or deleted, the cron job starts, restarts, or stops the RSS-specific cron job. The default config is defined by cron.checkRssConfigUpdates config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    checkRssConfigUpdates: {
    +      // minutely
    +      timeSpec: '0 * * * * *',
    +    },
    +  },
    +};
    +

    Note timeSpec doesn't control the RSS poll frequency (which is defined in dynamic configs and is service specific), instead it only controls the frequency to check for dynamic config changes.

    Delete Notification Bounces

    This cron job deletes notification bounces if the latest notification is deemed delivered successfully. The criteria of successful delivery are

    1. No bounce received since the latest notification started dispatching, and
    2. a configured time span has lapsed since the latest notification finished dispatching

    The default config is defined by cron.deleteBounces config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    deleteBounces: {
    +      // hourly
    +      timeSpec: '0 0 * * * *',
    +      minLapsedHoursSinceLatestNotificationEnded: 1,
    +    },
    +  },
    +};
    +

    where

    • minLapsedHoursSinceLatestNotificationEnded is the time span

    Re-dispatch Broadcast Push Notifications

    This cron job re-dispatches a broadcast push notifications when original request failed. It is part of guaranteed broadcast push dispatch processing

    The default config is defined by cron.reDispatchBroadcastPushNotifications config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    reDispatchBroadcastPushNotifications: {
    +      // minutely
    +      timeSpec: '0 * * * * *',
    +    },
    +  },
    +};
    +

    Clear Redis Datastore

    This cron job clears Redis datastore used for SMS and email throttle. The job is enabled only if Redis is used. Datastore is cleared only when there is no broadcast push notifications in sending state. Without this cron job, updated throttle settings in config file will never take effect, and staled jobs in Redis datastore will not be cleaned up.

    The default config is defined by cron.clearRedisDatastore config object in file /src/config.ts

    module.exports = {
    +  cron: {
    +    clearRedisDatastore: {
    +      // hourly
    +      timeSpec: '0 0 * * * *',
    +    },
    +  },
    +};
    +
    + + + diff --git a/preview/docs/config-database/index.html b/preview/docs/config-database/index.html new file mode 100644 index 000000000..b3b95671d --- /dev/null +++ b/preview/docs/config-database/index.html @@ -0,0 +1,38 @@ + + + + + + + + + Database | NotifyBC + + + + +

    Database

    By default NotifyBC uses in-memory database backed up by folder /server/database/ for local and docker deployment and MongoDB for Kubernetes deployment. To use MongoDB for non-Kubernetes deployment, add file /src/datasources/db.datasource.(local|<env>).(json|js|ts) with MongoDB connection information such as following:

    module.exports = {
    +  uri: 'mongodb://127.0.0.1:27017/notifyBC?replicaSet=rs0',
    +  user: process.env.MONGODB_USER,
    +  pass: process.env.MONGODB_PASSWORD,
    +};
    +

    See Mongoose connection optionsopen in new window for more configurable properties.

    + + + diff --git a/preview/docs/config-email/index.html b/preview/docs/config-email/index.html new file mode 100644 index 000000000..27042be66 --- /dev/null +++ b/preview/docs/config-email/index.html @@ -0,0 +1,123 @@ + + + + + + + + + Email | NotifyBC + + + + +

    Email

    SMTP

    By default NotifyBC acts as the SMTP server itself and connects directly to recipient's SMTP server. To setup SMTP relay to a host, say smtp.foo.com, add following smtp config object to /src/config.local.js

    module.exports = {
    +  email: {
    +    smtp: {
    +      host: 'smtp.foo.com',
    +      port: 25,
    +      pool: true,
    +      tls: {
    +        rejectUnauthorized: false,
    +      },
    +    },
    +  },
    +};
    +

    Check out Nodemaileropen in new window for other config options that you can define in smtp object. Using SMTP relay and fine-tuning some options are critical for performance. See benchmark advices.

    Throttle

    NotifyBC can throttle email requests if SMTP server imposes rate limit. To enable throttle and set rate limit, create following config in file /src/config.local.js

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +    },
    +  },
    +};
    +

    where

    • enabled - whether to enable throttle or not. Default to false.
    • minTime - minimum request interval in ms. Example value 250 throttles request rate to 4/sec.

    When NotifyBC is deployed from source code, by default the rate limit applies to one Node.js process only. If there are multiple processes, i.e. a cluster, the aggregated rate limit is multiplied by the number of processes. To enforce the rate limit across entire cluster, install Redis and add Redis config to email.throttle

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        host: '127.0.0.1',
    +        port: 6379,
    +      },
    +    },
    +  },
    +};
    +

    If you installed Redis Sentinel,

    module.exports = {
    +  email: {
    +    throttle: {
    +      enabled: true,
    +      // minimum request interval in ms
    +      minTime: 250,
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        name: 'mymaster',
    +        sentinels: [{ host: '127.0.0.1', port: 26379 }],
    +      },
    +    },
    +  },
    +};
    +

    Throttle is implemented using Bottleneckopen in new window and ioredisopen in new window. See their documentations for more configurations. The only deviation made by NotifyBC is using jobExpiration to denote Bottleneck expiration job option with a default value of 2min as defined in config.tsopen in new window.

    When NotifyBC is deployed to Kubernetes using Helm, by default throttle, if enabled, uses Redis Sentinel therefore rate limit applies to whole cluster.

    Inbound SMTP Server

    NotifyBC implemented an inbound SMTP server to handle

    In order for the emails from internet to reach the SMTP server, a host where one of the following servers should be listening on port 25 open to internet

    1. NotifyBC, if it can be installed on such internet-facing host directly; otherwise,
    2. a tcp proxy server, such as nginx with stream proxy module that can proxy tcp port 25 traffic to backend NotifyBC instances.

    Regardless which above option is chosen, you need to config NotifyBC inbound SMTP server by adding following static config email.inboundSmtpServer to file /src/config.local.js

    module.exports = {
    +  email: {
    +    inboundSmtpServer: {
    +      enabled: true,
    +      domain: 'host.foo.com',
    +      listeningSmtpPort: 25,
    +      options: {
    +        // ...
    +      },
    +    },
    +  },
    +};
    +

    where

    • enabled enables/disables the inbound SMTP server with default to true.
    • domain is the internet-facing host domain. It has no default so must be set.
    • listeningSmtpPort should be set to 25 if option 1 above is chosen. For options 2, listeningSmtpPort can be set to any opening port. On Unix, NotifyBC has to be run under root account to bind to port 25. If missing, NotifyBC will randomly select an available port upon launch which is usually undesirable so it should be set.
    • optional options object defines the behavior of Nodemailer SMTP Serveropen in new window.

    Inbound SMTP Server on OpenShift

    OpenShift deployment template deploys an inbound SMTP server. Due to the limitation that OpenShift can only expose port 80 and 443 to external, to use the SMTP server, you have to setup a TCP proxy server (i.e. option 2). The inbound SMTP server is exposed as ${INBOUND_SMTP_DOMAIN}:443 , where ${INBOUND_SMTP_DOMAIN} is a template parameter which in absence, a default domain will be created. Configure your TCP proxy server to route traffic to ${INBOUND_SMTP_DOMAIN}:443 over TLS.

    TCP Proxy Server

    If NotifyBC is not able to bind to port 25 that opens to internet, perhaps due to firewall restriction, you can setup a TCP Proxy Server such as Nginx with ngx_stream_proxy_moduleopen in new window. For example, the following nginx config will proxy SMTP traffic from port 25 to a NotifyBC inbound SMTP server running on OpenShift

    stream {
    +    server {
    +        listen 25;
    +        proxy_pass ${INBOUND_SMTP_DOMAIN}:443;
    +        proxy_ssl on;
    +        proxy_ssl_verify off;
    +        proxy_ssl_server_name on;
    +        proxy_connect_timeout 10s;
    +    }
    +}
    +

    Replace ${INBOUND_SMTP_DOMAIN} with the inbound SMTP server route domain.

    Bounce

    Bounces, or Non-Delivery Reports (NDRs), are system-generated emails informing sender of failed delivery. NotifyBC can be configured to receive bounces, record bounces, and automatically unsubscribe all subscriptions of a recipient if the number of recorded hard bounces against the recipient exceeds threshold. A deemed successful notification delivery deletes the bounce record.

    Although NotifyBC records all bounce emails, not all of them should count towards unsubscription threshold, but rather only the hard bounces - those which indicate permanent unrecoverable errors such as destination address no longer exists. In principle this can be distinguished using smtp response code. In practice, however, there are some challenges to make the distinction

    • the smtp response code is not fully standardized and may vary by recipient's smtp server so it's unreliable
    • there is no standard smtp header in bounce email to contain smtp response code. Often the response code is embedded in bounce email body.
    • the bounce email template varies by sender's smtp server

    To mitigate, NotifyBC defines several customizable string pattern filters in terms of regular expression. Only bounce emails matched the filters count towards unsubscription threshold. It's a matter of trial-and-error to get the correct filter suitable to your smtp server.

    to improve hard bounce recognition

    Send non-existing emails to several external email systems. Inspect the bounce messages for common string patterns. After gone live, review bounce records in web console from time to time to identify new bounce types and decide whether the bounce types qualify as hard bounce. To avoid false positives resulting in premature unsubscription, it is advisable to start with a high unsubscription threshold.

    Bounce handling involves four actions

    • during notification dispatching, envelop address is set to a VERPopen in new window in the form bn-{subscriptionId}-{unsubscriptionCode}@{inboundSmtpServerDomain} routed to NotifyBC inbound smtp server.
    • when a notification finished dispatching, the dispatching start and end time is recorded to all bounce records matching affects recipient addresses
    • when inbound smtp server receives a bounce message, it updates the bounce record by saving the message and incrementing the hard bounce count when the message matches the filter criteria. The filter criteria are regular expressions matched against bounce email subject and body, as well as regular expression to extract recipient's email address from bounce email body. It also unsubscribes the user from all subscriptions when the hard bounce count exceeds a predefined threshold.
    • A cron job runs periodically to delete bounce records if the latest notification is deemed delivered successfully.

    To setup bounce handling

    • set up inbound smtp server

    • verify config email.bounce.enabled is set to true or absent in /src/config.local.js

    • verify and adjust unsubscription threshold and bounce filter criteria if needed. Following is the default config in file /src/config.ts compatible with rfc 3464open in new window

      module.exports = {
      +  email: {
      +    bounce: {
      +      enabled: true,
      +      unsubThreshold: 5,
      +      subjectRegex: '',
      +      smtpStatusCodeRegex: '5\\.\\d{1,3}\\.\\d{1,3}',
      +      failedRecipientRegex:
      +        '(?:[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*|"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])',
      +    },
      +  },
      +};
      +

      where

      • unsubThreshold is the threshold of hard bounce count above which the user is unsubscribed from all subscriptions

      • subjectRegex is the regular expression that bounce message subject must match in order to count towards the threshold. If subjectRegex is set to empty string or undefined, then this filter is disabled.

      • smtpStatusCodeRegex is the regular expression that smtp status code embedded in the message body must match in order to count towards the threshold. The default value matches all rfc3463open in new window class 5 status codes. For a multi-part bounce message, the body limits to the one of the following parts by content type in descending order

        • message/delivery-status
        • html
        • plain text
      • failedRecipientRegex is the regular expression used to extract recipient's email address from bounce message body. This extracted recipient's email address is compared against the subscription record as a means of validation. If failedRecipientRegex is set to empty string or undefined, then this validation method is skipped. The default RegEx is taken from a stackoverflow answeropen in new window. For a multi-part bounce message, the body limits to the one of the following parts by content type in descending order

        • message/delivery-status
        • html
        • plain text
    • Change config of cron job Delete Notification Bounces if needed

    List-unsubscribe by Email

    Some email clients provide a consistent UI to unsubscribe if an unsubscription email address is supplied. For example, newer iOS built-in email app will display following banner

    list unsubscription

    To support this unsubscription method, NotifyBC implements a custom inbound SMTP server to transform received emails sent to address un-{subscriptionId}-{unsubscriptionCode}@{inboundSmtpServerDomain} to NotifyBC unsubscribing API calls. This unsubscription email address is generated by NotifyBC and set in header List-Unsubscribe of all notification emails.

    To enable list-unsubscribe by email

    • set up inbound smtp server
    • verify config email.listUnsubscribeByEmail.enabled is set to true or absent in /src/config.local.js

    To disable list-unsubscribe by email, set email.listUnsubscribeByEmail.enabled to false in /src/config.local.js

    module.exports = {
    +  email: {
    +    listUnsubscribeByEmail: { enabled: false },
    +  },
    +};
    +
    + + + diff --git a/preview/docs/config-httpHost/index.html b/preview/docs/config-httpHost/index.html new file mode 100644 index 000000000..2c779b3a3 --- /dev/null +++ b/preview/docs/config-httpHost/index.html @@ -0,0 +1,36 @@ + + + + + + + + + HTTP Host | NotifyBC + + + + +

    HTTP Host

    httpHost config sets the fallback http host used by

    • mail merge token substitution
    • internal HTTP requests spawned by NotifyBC

    httpHost can be overridden by other configs or data. For example

    • internalHttpHost config
    • httpHost field in a notification

    There are contexts where there is no alternatives to httpHost. Therefore this config should be defined.

    Define the config, which has no default value, in /src/config.local.js

    module.exports = {
    +  httpHost: 'http://foo.com',
    +};
    +
    + + + diff --git a/preview/docs/config-internalHttpHost/index.html b/preview/docs/config-internalHttpHost/index.html new file mode 100644 index 000000000..3f2473d24 --- /dev/null +++ b/preview/docs/config-internalHttpHost/index.html @@ -0,0 +1,36 @@ + + + + + + + + + Internal HTTP Host | NotifyBC + + + + +

    Internal HTTP Host

    By default, HTTP requests submitted by NotifyBC back to itself will be sent to httpHost if defined or the host of the incoming HTTP request that spawns such internal requests. But if config internalHttpHost, which has no default value, is defined, for example in file /src/config.local.js

    module.exports = {
    +  internalHttpHost: 'http://notifybc:3000',
    +};
    +

    then the HTTP request will be sent to the configured host. An internal request can be generated, for example, as a sub-request of broadcast push notification. internalHttpHost shouldn't be accessible from internet.

    All internal requests are supposed to be admin requests. The purpose of internalHttpHost is to facilitate identifying the internal server ip as admin ip.

    Kubernetes Use Case

    The Kubernetes deployment script sets internalHttpHost to notify-bc-app service url in config map. The source ip in such case would be in a private Kubernetes ip range. You should add this private ip range to admin ip list. The private ip range varies from Kubernetes installation. In BCGov's OCP4 cluster, it starts with octet 10.

    + + + diff --git a/preview/docs/config-middleware/index.html b/preview/docs/config-middleware/index.html new file mode 100644 index 000000000..7a43ed745 --- /dev/null +++ b/preview/docs/config-middleware/index.html @@ -0,0 +1,63 @@ + + + + + + + + + Middleware | NotifyBC + + + + +

    Middleware

    NotifyBC pre-installed following Expressopen in new window middleware as defined in /src/middleware.ts

    /src/middleware.ts contains following default middleware settings

    import path from 'path';
    +module.exports = {
    +  all: {
    +    compression: {},
    +  },
    +  apiOnly: {
    +    helmet: {},
    +    morgan: {
    +      params: [
    +        ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status ":req[X-Forwarded-For]"',
    +      ],
    +      enabled: false,
    +    },
    +  },
    +};
    +

    /src/middleware.ts has following structure

    module.exports = {
    +  all: {
    +    '<middlewareName>': {params: [], enabled: <boolean>},
    +  },
    +  apiOnly: {
    +    '<middlewareName>': {params: [], enabled: <boolean>},
    +  },
    +};
    +

    Middleware defined under all applies to both API and web console requests, as opposed to apiOnly, which applies to API requests only. params are passed to middleware function as arguments. enabled toggles the middleware on or off.

    To change default settings defined in /src/middleware.ts, create file /src/middleware.local.ts or /src/middleware.<env>.ts to override. For example, to enable access log,

    module.exports = {
    +  apiOnly: {
    +    morgan: {
    +      enabled: true,
    +    },
    +  },
    +};
    +
    + + + diff --git a/preview/docs/config-nodeRoles/index.html b/preview/docs/config-nodeRoles/index.html new file mode 100644 index 000000000..f6e714ceb --- /dev/null +++ b/preview/docs/config-nodeRoles/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Node Roles | NotifyBC + + + + + + + + diff --git a/preview/docs/config-notification/index.html b/preview/docs/config-notification/index.html new file mode 100644 index 000000000..ba591038c --- /dev/null +++ b/preview/docs/config-notification/index.html @@ -0,0 +1,104 @@ + + + + + + + + + Notification | NotifyBC + + + + +

    Notification

    Configs in this section customize the handling of notification request or generating notifications from RSS feeds. They are all sub-properties of config object notification. Service-agnostic configs are static and service-dependent configs are dynamic.

    RSS Feeds

    NotifyBC can generate broadcast push notifications automatically by polling RSS feeds periodically and detect changes by comparing with an internally maintained history list. The polling frequency, RSS url, RSS item change detection criteria, and message template can be defined in dynamic configs.

    Only first page is retrieved for paginated RSS feeds

    If a RSS feed is paginated, NotifyBC only retrieves the first page rather than auto-fetch subsequent pages. Hence paginated RSS feeds should be sorted descendingly by last modified timestamp. Refresh interval should be adjusted small enough such that all new or updated items are contained in first page.

    For example, to notify subscribers of myService on updates to feed http://my-serivce/rss, create following config item using POST configuration API

    {
    +  "name": "notification",
    +  "serviceName": "myService",
    +  "value": {
    +    "rss": {
    +      "url": "http://my-serivce/rss",
    +      "timeSpec": "* * * * *",
    +      "itemKeyField": "guid",
    +      "outdatedItemRetentionGenerations": 1,
    +      "includeUpdatedItems": true,
    +      "fieldsToCheckForUpdate": ["title", "pubDate", "description"]
    +    },
    +    "httpHost": "http://localhost:3000",
    +    "messageTemplates": {
    +      "email": {
    +        "from": "no_reply@invlid.local",
    +        "subject": "{title}",
    +        "textBody": "{description}",
    +        "htmlBody": "{description}"
    +      },
    +      "sms": {
    +        "textBody": "{description}"
    +      }
    +    }
    +  }
    +}
    +

    The config items in the value field are

    • rss
      • url: RSS url
      • timeSpec: RSS poll frequency, a space separated fields conformed to unix crontab formatopen in new window with an optional left-most seconds field. See allowed rangesopen in new window of each field
      • itemKeyField: rss item's unique key field to identify new items. By default guid
      • outdatedItemRetentionGenerations: number of last consecutive polls from which results an item has to be absent before the item can be removed from the history list. This config is designed to prevent multiple notifications triggered by the same item because RSS poll returns inconsistent results, usually due to a combination of pagination and lack of sorting. By default 1, meaning the history list only keeps the last poll result
      • includeUpdatedItems: whether to notify also updated items or just new items. By default false
      • fieldsToCheckForUpdate: list of fields to check for updates if includeUpdatedItems is true. By default ["pubDate"]
    • httpHost: the http protocol, host and port used by mail merge. If missing, the value is auto-populated based on the REST request that creates this config item.
    • messageTemplates: channel-specific message templates with channel name as the key. NotifyBC generates a notification for each channel specified in the message templates. Message template fields are the same as those in notification api. Message template fields support dynamic token.

    Broadcast Push Notification Task Concurrency

    To achieve horizontal scaling, when a broadcast push notification request, hereby known as original request, is received, NotifyBC divides subscribers into chunks and generates a HTTP sub-request for each chunk. The original request supervises the execution of sub-requests. The chunk size is defined by config broadcastSubscriberChunkSize. All subscribers in a sub-request chunk are processed concurrently when the sub-requests are submitted.

    The original request submits sub-requests back to (preferably load-balanced) NotifyBC server cluster for processing. Sub-request submission is throttled by config broadcastSubRequestBatchSize. broadcastSubRequestBatchSize defines the upper limit of the number of Sub-requests that can be processed at any given time.

    As an example, assuming the total number of subscribers for a notification is 1,000,000, broadcastSubscriberChunkSize is 1,000 and broadcastSubRequestBatchSize is 10, NotifyBC will divide the 1M subscribers into 1,000 chunks and generates 1,000 sub-requests, one for each chunk. The 1,000 sub-requests will be submitted back to NotifyBC cluster to be processed. The original request will ensure at most 10 sub-requests are submitted and being processed at any given time. In fact, the only time concurrency is less than 10 is near the end of the task when remaining sub-requests is less than 10. When a sub-request is received by NotifyBC cluster, all 1,000 subscribers are processed concurrently. Suppose each sub-request (i.e. 1,000 subscribers) takes 1 minute to process on average, then the total time to dispatch notifications to 1M subscribers takes 1,000/10 = 100min, or 1hr40min.

    The default value for broadcastSubscriberChunkSize and broadcastSubRequestBatchSize are defined in /src/config.ts

    module.exports = {
    +  notification: {
    +    broadcastSubscriberChunkSize: 1000,
    +    broadcastSubRequestBatchSize: 10,
    +  },
    +};
    +

    To customize, create the config with updated value in file /src/config.local.js.

    If total number of subscribers is less than broadcastSubscriberChunkSize, then no sub-requests are spawned. Instead, the main request dispatches all notifications.

    How to determine the optimal value for broadcastSubscriberChunkSize and broadcastSubRequestBatchSize?

    broadcastSubscriberChunkSize is determined by the concurrency capability of the downstream message handlers such as SMTP server or SMS service provider. broadcastSubRequestBatchSize is determined by the size of NotifyBC cluster. As a rule of thumb, set broadcastSubRequestBatchSize equal to the number of non-master nodes in NotifyBC cluster.

    Broadcast Push Notification Custom Filter Functions

    Advanced Topic

    Defining custom function requires knowledge of JavaScript and understanding how external libraries are added and referenced in Node.js. Setting a development environment to test the custom function is also recommended.

    To support rule-based notification event filtering, NotifyBC uses a modified versionopen in new window of jmespathopen in new window to implement json query. The modified version allows defining custom functions that can be used in broadcastPushNotificationFilter field of subscription API and broadcastPushNotificationSubscriptionFilter field of subscription API. The functions must be implemented using JavaScript in config notification.broadcastCustomFilterFunctions. The functions can even be async. For example, the case-insensitive string matching function contains_ci shown in the example of that field can be created in file /src/config.local.js

    const _ = require('lodash')
    +module.exports = {
    +  ...
    +  notification: {
    +    broadcastCustomFilterFunctions: {
    +      contains_ci: {
    +        _func: async function(resolvedArgs) {
    +          if (!resolvedArgs[0] || !resolvedArgs[1]) {
    +            return false
    +          }
    +          return _.toLower(resolvedArgs[0]).indexOf(_.toLower(resolvedArgs[1])) >= 0
    +        },
    +        _signature: [
    +          {
    +            types: [2]
    +          },
    +          {
    +            types: [2]
    +          }
    +        ]
    +      }
    +    }
    +  }
    +}
    +

    Consult jmespath.js source code on the functionTable syntaxopen in new window and type constantsopen in new window used by above code. Note the function can use any Node.js modules (lodashopen in new window in this case).

    install additional Node.js modules

    The recommended way to install additional Node.js modules is by running command npm install <your_module> from the directory one level above NotifyBC root. For example, if NotifyBC is installed on /data/notifyBC, then run the command from directory /data. The command will then install the module to /data/node_modules/<your_module>.

    Guaranteed Broadcast Push Dispatch Processing

    As a major enhancement in v3, by default NotifyBC guarantees all subscribers of a broadcast push notification will be processed in spite of NotifyBC node failures during dispatching. Node failure is a concern because the time takes to dispatch broadcast push notification is proportional to number of subscribers, which is potentially large.

    The guarantee is achieved by

    1. logging the dispatch result to database individually right after each dispatch
    2. when subscribers are divided into chunks and a chunk sub-request fails, the original request re-submits the sub-request
    3. the original request periodically updates the notification updated timestamp field as heartbeat during dispatching
    4. if original request fails,
      1. a cron job detects the failure from the stale timestamp, and re-submits the original request
      2. all chunk sub-requests detects the the failure from the socket error, and stop processing

    Guaranteed processing doesn't mean notification will be dispatched to every intended subscriber, however. Dispatch can still be rejected by smtp/sms server. Furthermore, even if dispatch is successful, it only means the sending is successful. It doesn't guarantee the recipient receives the notification. Bounce may occur for a successful dispatch, for instance; or the recipient may not read the message.

    The guarantee comes at a performance penalty because result of each dispatch is written to database one by one, taking a toll on the database. It should be noted that the benchmarks were conducted without the guarantee.

    If performance is a higher priority to you, disable both the guarantee and bounce handling by setting config notification.guaranteedBroadcastPushDispatchProcessing and email.bounce.enabled to false in file /src/config.local.js

    module.exports = {
    +  notification: {
    +    guaranteedBroadcastPushDispatchProcessing: false,
    +  },
    +  email: {
    +    bounce: {enabled: false},
    +  },
    +};
    +

    In such case only failed dispatches are written to dispatch.failed field of the notification.

    Also log skipped dispatches for broadcast push notifications

    When guaranteedBroadcastPushDispatchProcessing is true, by default only successful and failed dispatches are logged, along with dispatch candidates. Dispatches that are skipped by filters defined at subscription (broadcastPushNotificationFilter) or notification (broadcastPushNotificationSubscriptionFilter) are not logged for performance reason. If you also want skipped dispatches to be logged to dispatch.skipped field of the notification, set logSkippedBroadcastPushDispatches to true in file /src/config.local.js

    module.exports = {
    +  ...
    +  notification: {
    +    ...
    +    logSkippedBroadcastPushDispatches: true,
    +  }
    +}
    +

    Setting logSkippedBroadcastPushDispatches to true only has effect when guaranteedBroadcastPushDispatchProcessing is true.

    + + + diff --git a/preview/docs/config-oidc/index.html b/preview/docs/config-oidc/index.html new file mode 100644 index 000000000..f55eddef7 --- /dev/null +++ b/preview/docs/config-oidc/index.html @@ -0,0 +1,49 @@ + + + + + + + + + OIDC | NotifyBC + + + + +

    OIDC

    NotifyBC currently can only authenticate RSA signed OIDC access token if the token is a JWT. OIDC providers such as Keycloak meet the requirement.

    To enable OIDC authentication strategy, add oidc configuration object to /src/config.local.js. The object supports following properties

    1. discoveryUrl - OIDC discoveryopen in new window url
    2. clientId - OIDC client id
    3. isAdmin - a predicate function to determine if authenticated user is NotifyBC administrator. The function takes the decoded OIDC access token JWT payload as input user object and should return either a boolean or a promise of boolean, i.e. the function can be both sync or async.
    4. isAuthorizedUser - an optional predicate function to determine if authenticated user is an authorized NotifyBC user. If omitted, any authenticated user is authorized NotifyBC user. This function has same signature as isAdmin

    A example of complete OIDC configuration looks like

    module.exports = {
    +  ...
    +  oidc: {
    +    discoveryUrl:
    +      'https://op.example.com/auth/realms/foo/.well-known/openid-configuration',
    +    clientId: 'NotifyBC',
    +    isAdmin(user) {
    +      const roles = user.resource_access.NotifyBC.roles;
    +      if (!(roles instanceof Array) || roles.length === 0) return false;
    +      return roles.indexOf('admin') > -1;
    +    },
    +    isAuthorizedUser(user) {
    +      return user.realm_access.roles.indexOf('offline_access') > -1;
    +    },
    +  },
    +};
    +

    In NotifyBC web console and only in the web console, OIDC authentication takes precedence over built-in admin user, meaning if OIDC is configured, the login button goes to OIDC provider rather than the login form.

    There is no default OIDC configuration in /src/config.ts.

    + + + diff --git a/preview/docs/config-overview/index.html b/preview/docs/config-overview/index.html new file mode 100644 index 000000000..1f3d73bfe --- /dev/null +++ b/preview/docs/config-overview/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Configuration Overview | NotifyBC + + + + +

    Configuration Overview

    Helm Chart Configurations

    The document pages in this section cover NoitfyBC app level configurations only. If your NotifyBC is deployed to Kubernetes using Helm, you can also customize infrastructure level configurations.

    There are two types of configurations - static and dynamic. Static configurations are defined in files or environment variables, requiring restarting NotifyBC to take effect, whereas dynamic configurations are defined in databases and updates take effect immediately.

    Static Configurations

    Most static configurations are specified in file /src/config.ts. If you need to change, instead of updating /src/config.ts file, create local file /src/config.local.js or environment specific file /src/config.<env>.js, which is only included when environment variable NODE_ENV equals <env>. Besides js, ts and json file extensions are also supported. The rest of the documentation assumes the file extension is js. Content in these files are deeply merged in following ascending precedence

    • default file /src/config.ts
    • environment specific file /src/config.<env>.js
    • local file /src/config.local.js

    Run build script whenever changing file in /src

    Every time a file under /src, including config files, is updated, run npm run build before restarting NotifyBC to take effect.

    Following configs should be customized per installation

    In addition, if installing from source code

    Customizing other configs only if needed.

    Dynamic Configurations

    Dynamic configs are managed using REST configuration api.

    Why Dynamic Configs?

    Dynamic configs are needed in cases such as

    • to allow define service-specific configs such as message templates
    • in a multi-node deployment, configs can be generated by one node (typically master) and shared with other nodes
    + + + diff --git a/preview/docs/config-reverseProxyIpLists/index.html b/preview/docs/config-reverseProxyIpLists/index.html new file mode 100644 index 000000000..1bb97b44e --- /dev/null +++ b/preview/docs/config-reverseProxyIpLists/index.html @@ -0,0 +1,40 @@ + + + + + + + + + Reverse Proxy IP Lists | NotifyBC + + + + +

    Reverse Proxy IP Lists

    SiteMinder, being a gateway approached SSO solution, expects the backend HTTP access point of the web sites it protests to be firewall restricted, otherwise the SiteMinder injected HTTP headers can be easily spoofed. However, the restriction cannot be easily implemented on PAAS such as OpenShift. To mitigate, two configuration objects are introduced to create an application-level firewall, both are arrays of ip addresses in the format of dot-decimalopen in new window or CIDRopen in new window notation

    • siteMinderReverseProxyIps contains a list of ips or ranges of SiteMinder Web Agents. If set, then the SiteMinder HTTP headers are trusted only if the request is routed from the listed nodes.
    • trustedReverseProxyIps contains a list of ips or ranges of trusted reverse proxies. If NotifyBC is placed behind SiteMinder Web Agents, then trusted reverse proxies should include only those between SiteMinder Web Agents and NotifyBC application. When running on OpenShift, this is usually the OpenShift router. Express.js trust proxyopen in new window is set to this config object.

    By default trustedReverseProxyIps is empty and siteMinderReverseProxyIps contains only localhost as defined in /src/config.ts

    module.exports = {
    +  siteMinderReverseProxyIps: ['127.0.0.1'],
    +};
    +

    To modify, add following objects to file /src/config.local.js

    module.exports = {
    +  siteMinderReverseProxyIps: ['130.32.12.0'],
    +  trustedReverseProxyIps: ['172.17.0.0/16'],
    +};
    +

    The rule to determine if the incoming request is authenticated by SiteMinder is

    1. obtain the real client ip address by filtering out trusted proxy ips according to Express behind proxiesopen in new window
    2. if the real client ip is contained in siteMinderReverseProxyIps, then the request is from SiteMinder, and its SiteMinder headers are trusted; otherwise, the request is considered as directly from internet, and its SiteMinder headers are ignored.
    + + + diff --git a/preview/docs/config-rsaKeys/index.html b/preview/docs/config-rsaKeys/index.html new file mode 100644 index 000000000..566fbdf7d --- /dev/null +++ b/preview/docs/config-rsaKeys/index.html @@ -0,0 +1,46 @@ + + + + + + + + + RSA Keys | NotifyBC + + + + +

    RSA Keys

    When NotifyBC starts up, it checks if an RSA key pair exists in database as dynamic config. If not it will generate the dynamic config and save it to database. This RSA key pair is used to exchange confidential information with third party server applications through user's browser. For an example of use case, see Subscription API. To make it work, send the public key to the third party and have their server app encrypt the data using the public key. To obtain public key, call the REST Configuration API from an admin ip, for example, by running cURL command

    curl -X GET 'http://localhost:3000/api/configurations?filter=%7B%22where%22%3A%20%7B%22name%22%3A%20%22rsa%22%7D%7D'
    +

    or you can open API explorer, expand GET /configurations and set filter to

    {"where": {"name": "rsa"}}
    +

    The response should be something like

    [
    +  {
    +    "name": "rsa",
    +    "value": {
    +      "private": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpgIBAAKCAQEA8Hl+/cF3AOxKVRHtZpeSDM+LLGc2hkDkKxRXe72maUAzDUoO\noNd6wd02Cf6iO7kj0RSDHXUyINxCgvXy2Q7gME4zRN5WG4ItWZ7FITeNgJJW1r+J\nshDjTwKVpMvcKHy0vyUl25ah7hnwGK6PbJvFWMmtIBw6Rs5DaERAlmilgkuUgdri\naA4YhhS4pCJLvO2p9wZd+dLWUT+tpsOZGeecC8If3fyShgrocMbd8pYYDzf65oCt\nVaLaNdERaIJSDcmbHxFpeBdEQEzxw2qRPbUCnSgQb8cVFLJ2eOEn5LylWhU96A1S\n3w1IlRm5N2zG0En58Vruo26gEtl5KFu0zivlawIDAQABAoIBAQCAawFsFcKtVYIk\nh9xVax/tg2/5GG0/qKuwbb6CMDcMAeLBeAjzz96YZL+U+sw8RJRh9ShHtOw+LCHA\nugMj8xO5+Cjc4DbvnccGEwmGwZnpTTzelY687tPUv7aWON+rJ12GrhnXeEulUWis\nZZvmDhGHZrvzZ9+fLEtHBRvQtrWcLCN0G5l1Z1KEWUj23vn1HZpfNvqigIbC05Pq\nWUewRZShHUklhzky6DwLklWUKv2951ypd5CHhYfXn0eXjeyqcoYeZzoCSGqtvZar\nVVOCPBKPn3cLZVKzYd02WO4CV07SpHCBtYPWf4OvXbOY6wV1Vc92S0K+ijASDDc0\nB7Vjgb8RAoGBAPg4dSbn9GWNHydveidi2Zt4kftEW18C9xHbJ3t+QkhpLjq2kwcY\no0iOWkEd4d1l0lKAVanBQazrazKiSyq1PJSJDyL3osHItA7Twq+gCXOfXw/0LbJh\napK5DH3S2ZTM42wOdZLYIHvSqRuYUmnzhy9+Ads87b/ICCctUMCLz1afAoGBAPgC\n4/zE/Au/A3wb48AywfmJ5kqPO0V7lqLrn/aBwdF1H/DHQ95cSuKrTEIysZxz52bh\n7mAHjnWnY4zFNaUvcruHw78NOxUJvje8cDIUsrTefh+qmctiGR119z7iso9FlsxR\np/o5BVT/K8q76xtkpOln2A0rc8sBNwtCoeeUzfm1AoGBAInH/O99raF49iQTswCN\n1DCCerW4uedBZBebSI06BlzfVXPtyCsWN/ycV+jxR2B3lomJBwPVbDkp7DUM9SBd\nvaTNd4N3ZfafC6N3VAfck6KEgmX+qibsABY1dYOaOIBqQorGc+jw4wcYZhoVMRny\nvcVU8n7ZkTb1N+FXPA3FDXANAoGBAOuSg0/TI71cgEjgjOJA1DLco1vq1NfY3mp9\n+QFCmwEDiYVBINwTOiY3o0W1tTLwfLoinDOmudBTYKGTqLLwcMBj4rCUNqxzBrUW\nTlOjiWN3esFFYLPoyAZNyL14wzaHWQdWAIISq1fi0IvPFzB71pDFTFimD2SiENCn\nR/YaR9OJAoGBAM21MRvTEMHF/EvqZ/X6t2zm9dtA22L2LeVy68aEdo82F/1RFvCM\nGBWjGS7G7fXk/tV/YHbjibhgktvLu3Rss1wlHfGEjtDAIdp9dqH0cNxMgy/eTfoy\nFfzV3l7pNSdILn1bNqoMz9CaYK7CGIYpBWCbRJlRSYw2FHJwl5tzgmkk\n-----END RSA PRIVATE KEY-----",
    +      "public": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Hl+/cF3AOxKVRHtZpeS\nDM+LLGc2hkDkKxRXe72maUAzDUoOoNd6wd02Cf6iO7kj0RSDHXUyINxCgvXy2Q7g\nME4zRN5WG4ItWZ7FITeNgJJW1r+JshDjTwKVpMvcKHy0vyUl25ah7hnwGK6PbJvF\nWMmtIBw6Rs5DaERAlmilgkuUgdriaA4YhhS4pCJLvO2p9wZd+dLWUT+tpsOZGeec\nC8If3fyShgrocMbd8pYYDzf65oCtVaLaNdERaIJSDcmbHxFpeBdEQEzxw2qRPbUC\nnSgQb8cVFLJ2eOEn5LylWhU96A1S3w1IlRm5N2zG0En58Vruo26gEtl5KFu0zivl\nawIDAQAB\n-----END PUBLIC KEY-----"
    +    },
    +    "id": "591cda5d6c7adec42a1874bc",
    +    "updated": "2017-05-17T23:18:53.385Z"
    +  }
    +]
    +

    The public key is the string -----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----

    In a multi-node deployment, when the cluster is first started up, database is empty and rsa key pair doesn't exist. To prevent multiple rsa keys being generated by different nodes, only the master node can generate the rsa key pair. other nodes will wait for the key pair available in database before proceeding with rest bootstrap.

    Expose RSA public key to only trusted party

    Despite of the adjective public, NotifyBC's public key should only be distributed to trusted third party. The trusted third party should only use the public key at server backend. Using the public key in client-side JavaScript poses a security loophole.

    + + + diff --git a/preview/docs/config-sms/index.html b/preview/docs/config-sms/index.html new file mode 100644 index 000000000..0297409fa --- /dev/null +++ b/preview/docs/config-sms/index.html @@ -0,0 +1,106 @@ + + + + + + + + + SMS | NotifyBC + + + + +

    SMS

    Provider

    NotifyBC depends on underlying SMS service providers to deliver SMS messages. The supported service providers are

    Only one service provider can be chosen per installation. To change service provider, add following config to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    provider: 'swift',
    +  },
    +};
    +

    Provider Settings

    Provider specific settings are defined in config sms.providerSettings. You should have an account with the chosen service provider before proceeding.

    Twilio

    Add sms.providerSettings.twilio config object to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    providerSettings: {
    +      twilio: {
    +        accountSid: '<AccountSid>',
    +        authToken: '<AuthToken>',
    +        fromNumber: '<FromNumber>',
    +      },
    +    },
    +  },
    +};
    +

    Obtain <AccountSid>, <AuthToken> and <FromNumber> from your Twilio account.

    Swift

    Add sms.providerSettings.swift config object to file /src/config.local.js

    module.exports = {
    +  sms: {
    +    providerSettings: {
    +      swift: {
    +        accountKey: '<accountKey>',
    +      },
    +    },
    +  },
    +};
    +

    Obtain <accountKey> from your Swift account.

    Unsubscription by replying a keyword

    With Swift short code, sms user can unsubscribe by replying to a sms message with a keyword. The keyword must be pre-registered with Swift.

    To enable this feature,

    1. Generate a random string, hereafter referred to as <randomly-generated-string>

    2. Add it to sms.providerSettings.swift.notifyBCSwiftKey in file /src/config.local.js

      module.exports = {
      +  sms: {
      +    providerSettings: {
      +      swift: {
      +        notifyBCSwiftKey: '<randomly-generated-string>',
      +      },
      +    },
      +  },
      +};
      +
    3. Go to Swift web admin console, click Number Management tab

    4. Click Launch button next to Manage Short Code Keywords

    5. Click Features button next to the registered keyword(s). A keyword may have multiple entries. In such case do this for each entry.

    6. Click Redirect To Webpage tab in the popup window

    7. Enter following information in the tab

      • set URL to <NotifyBCHttpHost>/api/subscriptions/swift, where <NotifyBCHttpHost> is NotifyBC HTTP host name and should be the same as HTTP Host config
      • set Method to POST
      • set Custom Parameter 1 Name to notifyBCSwiftKey
      • set Custom Parameter 1 Value to <randomly-generated-string>
    8. Click Save Changes button and then Done

    Throttle

    All supported SMS service providers impose request rate limit. NotifyBC by default throttles request rate to 4/sec. To adjust the rate, create following config in file /src/config.local.js

    module.exports = {
    +  sms: {
    +    throttle: {
    +      // minimum request interval in ms
    +      minTime: 250,
    +    },
    +  },
    +};
    +

    When NotifyBC is deployed from source code, by default the rate limit applies to one Node.js process only. If there are multiple processes, i.e. a cluster, the aggregated rate limit is multiplied by the number of processes. To enforce the rate limit across entire cluster, install Redis and add Redis config to sms.throttle

    module.exports = {
    +  sms: {
    +    throttle: {
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        host: '127.0.0.1',
    +        port: 6379,
    +      },
    +    },
    +  },
    +};
    +

    If you installed Redis Sentinel,

    module.exports = {
    +  sms: {
    +    throttle: {
    +      /* Redis clustering options */
    +      datastore: 'ioredis',
    +      clientOptions: {
    +        name: 'mymaster',
    +        sentinels: [{ host: '127.0.0.1', port: 26379 }],
    +      },
    +    },
    +  },
    +};
    +

    Throttle is implemented using Bottleneckopen in new window and ioredisopen in new window. See their documentations for more configurations. The only deviation made by NotifyBC is using jobExpiration to denote Bottleneck expiration job option with a default value of 2min as defined in config.tsopen in new window.

    When NotifyBC is deployed to Kubernetes using Helm, by default throttle, if enabled, uses Redis Sentinel therefore rate limit applies to whole cluster.

    To disable throttle, set sms.throttle.enabled to false in file /src/config.local.js

    module.exports = {
    +  sms: {
    +    throttle: {
    +      enabled: false,
    +    },
    +  },
    +};
    +
    + + + diff --git a/preview/docs/config-subscription/index.html b/preview/docs/config-subscription/index.html new file mode 100644 index 000000000..89d7484f7 --- /dev/null +++ b/preview/docs/config-subscription/index.html @@ -0,0 +1,174 @@ + + + + + + + + + Subscription | NotifyBC + + + + +

    Subscription

    Configs in this section customize behavior of subscription and unsubscription workflow. They are all sub-properties of config object subscription. This object can be defined as service-agnostic static config as well as service-specific dynamic config, which overrides the static one on a service-by-service basis. Default static config is defined in file /src/config.ts. There is no default dynamic config.

    To customize static config, create the config object subscription in file /src/config.local.js

    module.exports = {
    +  "subscription": {
    +    ...
    +  }
    +}
    +

    to create a service-specific dynamic subscription config, use REST config api

    curl -X POST http://localhost:3000/api/configurations \
    +-H 'Content-Type: application/json' \
    +-H 'Accept: application/json' -d @- << EOF
    +{
    +  "name": "subscription",
    +  "serviceName": "myService",
    +  "value": {
    +     ...
    +  }
    +}
    +EOF
    +

    Sub-properties denoted by ellipsis in the above two code blocks are documented below. A service can have at most one dynamic subscription config.

    Confirmation Request Message

    To prevent NotifyBC from being used as spam engine, when a subscription request is sent by user (as opposed to admin) without encryption, the content of confirmation request sent to user's notification channel has to come from a pre-configured template as opposed to be specified in subscription request.

    The following default subscription sub-property confirmationRequest defines confirmation request message settings for different channels

    {
    +  "subscription": {
    +    ...
    +    "confirmationRequest": {
    +      "sms": {
    +        "confirmationCodeRegex": "\\d{5}",
    +        "sendRequest": true,
    +        "textBody": "Enter {confirmation_code} on screen"
    +      },
    +      "email": {
    +        "confirmationCodeRegex": "\\d{5}",
    +        "sendRequest": true,
    +        "from": "no_reply@invlid.local",
    +        "subject": "Subscription confirmation",
    +        "textBody": "Enter {confirmation_code} on screen",
    +        "htmlBody": "Enter {confirmation_code} on screen"
    +      }
    +    }
    +  }
    +}
    +

    Confirmation Verification Acknowledgement Messages

    You can customize NotifyBC's on-screen response message to confirmation code verification requests. The following is the default settings

    {
    +  "subscription": {
    +    ...
    +    "confirmationAcknowledgements": {
    +      "successMessage": "You have been subscribed.",
    +      "failureMessage": "Error happened while confirming subscription."
    +    }
    +  }
    +}
    +

    In addition to customizing the message, you can define a redirect URL instead of displaying successMessage or failureMessage. For example, to redirect on-screen acknowledgement to a page in your app for service myService, create a dynamic config by calling REST config api

    curl -X POST 'http://localhost:3000/api/configurations' \
    +-H 'Content-Type: application/json' \
    +-H 'Accept: application/json' -d @- << EOF
    +{
    +  "name": "subscription",
    +  "serviceName": "myService",
    +  "value": {
    +    "confirmationAcknowledgements": {
    +      "redirectUrl": "https://myapp/subscription/acknowledgement"
    +    }
    +  }
    +}
    +EOF
    +

    If error happened during subscription confirmation, query string ?err=<error> will be appended to redirectUrl.

    Duplicated Subscription

    NotifyBC by default allows a user subscribe to a service through same channel multiple times. If this is undesirable, you can set config subscription.detectDuplicatedSubscription to true. In such case instead of sending user a confirmation request, NotifyBC sends user a duplicated subscription notification message. Unlike a confirmation request, duplicated subscription notification message either shouldn't contain any information to allow user confirm the subscription, or it should contain a link that allows user to replace existing confirmed subscription with this one. You can customize duplicated subscription notification message by setting config subscription.duplicatedSubscriptionNotification in either config.local.js or using configuration api for service-specific dynamic config. Following is the default settings defined in config.json

    {
    +  ...
    +  "subscription": {
    +    ...
    +    "detectDuplicatedSubscription": false,
    +    "duplicatedSubscriptionNotification": {
    +      "sms": {
    +        "textBody": "A duplicated subscription was submitted and rejected. you will continue receiving notifications. If the request was not created by you, pls ignore this msg."
    +      },
    +      "email": {
    +        "from": "no_reply@invalid.local",
    +        "subject": "Duplicated Subscription",
    +        "textBody": "A duplicated subscription was submitted and rejected. you will continue receiving notifications. If the request was not created by you, please ignore this message."
    +      }
    +    }
    +  }
    +}
    +

    To allow user to replace existing confirmed subscription, set the message to something like

    {
    +  ...
    +  "subscription": {
    +    ...
    +    "detectDuplicatedSubscription": false,
    +    "duplicatedSubscriptionNotification": {
    +      "email": {
    +        "textBody": "A duplicated subscription was submitted. If the request is not submitted by you, please ignore this message. Otherwise if you want to replace existing subscription with this one, click {subscription_confirmation_url}&replace=true."
    +      }
    +    }
    +  }
    +}
    +

    The query parameter &replace=true following the token {subscription_confirmation_url} will cause existing subscription be replaced.

    Anonymous Unsubscription

    For anonymous subscription, NotifyBC supports one-click opt-out by allowing unsubscription URL provided in notifications. To thwart unauthorized unsubscription attempts, NotifyBC implemented and enabled by default two security measurements

    • Anonymous unsubscription request requires unsubscription code, which is a random string generated at subscription time. Unsubscription code reduces brute force attack risk by increasing size of key space. Without it, an attacker only needs to successfully guess subscription id. Be aware, however, the unsubscription code has to be embedded in unsubscription link. If the user forwarded a notification to other people, he/she is still vulnerable to unauthorized unsubscription.
    • Acknowledgement notification - a (final) notification is sent to user acknowledging unsubscription, and offers a link to revert had the change been made unauthorized. A deleted subscription (unsubscription) may have a limited lifetime (30 days by default) according to retention policy defined in cron jobs so the reversion can only be performed within the lifetime.

    You can customize anonymous unsubscription settings by changing the anonymousUnsubscription configuration. Following is the default settings defined in config.jsonopen in new window

    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      code: {
    +        required: true,
    +        regex: '\\d{5}',
    +      },
    +      acknowledgements: {
    +        onScreen: {
    +          successMessage: 'You have been un-subscribed.',
    +          failureMessage: 'Error happened while un-subscribing.',
    +        },
    +        notification: {
    +          email: {
    +            from: 'no_reply@invalid.local',
    +            subject: 'Un-subscription acknowledgement',
    +            textBody:
    +              'This is to acknowledge you have been un-subscribed from receiving notification for {unsubscription_service_names}. If you did not authorize this change or if you changed your mind, open {unsubscription_reversion_url} to revert.',
    +            htmlBody:
    +              'This is to acknowledge you have been un-subscribed from receiving notification for {unsubscription_service_names}. If you did not authorize this change or if you changed your mind, click <a href="{unsubscription_reversion_url}">here</a> to revert.',
    +          },
    +        },
    +      },
    +    },
    +  },
    +};
    +

    The settings control whether or not unsubscription code is required, its RegEx pattern, and acknowledgement message templates for both on-screen and push notifications. Customization should be made to file /src/config.local.js for static config or using configuration api for service-specific dynamic config.

    To disable acknowledgement notification, set subscription.anonymousUnsubscription.acknowledgements.notification or a specific channel underneath to null

    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      acknowledgements: {
    +        notification: null,
    +      },
    +    },
    +  },
    +};
    +

    For on-screen acknowledgement, you can define a redirect URL instead of displaying successMessage or failureMessage. For example, to redirect on-screen acknowledgement to a page in your app for all services, create following config in file /src/config.local.js

    module.exports = {
    +  subscription: {
    +    anonymousUnsubscription: {
    +      acknowledgements: {
    +        onScreen: {
    +          redirectUrl: 'https://myapp/unsubscription/acknowledgement',
    +        },
    +      },
    +    },
    +  },
    +};
    +

    If error happened during unsubscription, query string ?err=<error> will be appended to redirectUrl.

    You can customize message displayed on-screen when user clicks revert unsubscription link in the acknowledgement notification. The default settings are

    {
    +  "subscription": {
    +    "anonymousUndoUnsubscription": {
    +      "successMessage": "You have been re-subscribed.",
    +      "failureMessage": "Error happened while re-subscribing."
    +    }
    +  }
    +}
    +

    You can redirect the message page by defining anonymousUndoUnsubscription.redirectUrl.

    + + + diff --git a/preview/docs/config-workerProcessCount/index.html b/preview/docs/config-workerProcessCount/index.html new file mode 100644 index 000000000..9013483a3 --- /dev/null +++ b/preview/docs/config-workerProcessCount/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Worker Process Count | NotifyBC + + + + +

    Worker Process Count

    When NotifyBC runs on a host with multiple CPUs, by default it creates a cluster of worker processes of which the count matches CPU count. You can override the number with the environment variable NOTIFYBC_WORKER_PROCESS_COUNT.

    A note about worker process count on OpenShift

    It has been observed that on OpenShift Node.js returns incorrect CPU count. The template therefore sets NOTIFYBC_WORKER_PROCESS_COUNT to 1. After all, on OpenShift NotifyBC is expected to be horizontally scaled by pods rather by CPUs.

    + + + diff --git a/preview/docs/developer-notes/index.html b/preview/docs/developer-notes/index.html new file mode 100644 index 000000000..7956654f6 --- /dev/null +++ b/preview/docs/developer-notes/index.html @@ -0,0 +1,35 @@ + + + + + + + + + Developer Notes | NotifyBC + + + + +

    Developer Notes

    Setup development environment

    Install Visual Studio Code and following extensions:

    • Prettier
    • ESLint
    • Vetur
    • Code Spell Checker
    • Debugger for Chrome

    Multiple run configs have been created to facilitate debugging server, client, test and docs.

    Client certificate authentication doesn't work in client debugger

    Because Vue cli webpack dev server cannot proxy passthrough HTTPS connections, client certificate authentication doesn't work in client debugger. If testing client certificate authentication in web console is needed, run npm run build to generate prod client distribution and launch server debugger on https://localhost:3000

    Automated Testing

    NotifyBC uses Jestopen in new window test framework bundled in NestJS. To launch test, run npm run test:e2e. A Test launch config is provided to debug in VS Code.

    Github Actions runs tests as part of the build. All test scripts should be able to run unattended, headless, quickly and depend only on local resources.

    Writing Test Specs

    Thanks to supertestopen in new window and MongoDB In-Memory Serveropen in new window, test specs can be written to cover nearly end-to-end request processing workflow (only sendMail and sendSMS need to be mocked). This allows test specs to anchor onto business requirements rather than program units such as functions or files, resulting in regression tests that are more resilient to code refactoring. Whenever possible, a test spec should be written to

    • start at a processing phase as early as possible. For example, to test a REST end point, start with the HTTP user request.
    • assert outcome of a processing phase as late and down below as possible - the HTTP response body/code, the database record created, for example.
    • avoid asserting middleware function input/output to facilitate code refactoring.
    • mock email/sms sending function (implemented by default). Inspect the input of the function, or at least assert the function has been called.

    Install Docs Website

    If you want to contribute to NotifyBC docs beyond simple fix ups, run

    cd docs && npm install && npm run dev
    +

    If everything goes well, the last line of the output will be

    > VuePress dev server listening at http://localhost:8080/NotifyBC/
    +

    You can now browse to the local docs site http://localhost:8080/NotifyBCopen in new window

    Publish Version Checklist

    1. update version in package.json
    2. update version appVersion in helm/Chart.yaml (major/minor only)
    3. update What's new (major/minor only)
    4. create a new Github release
    + + + diff --git a/preview/docs/health-check/index.html b/preview/docs/health-check/index.html new file mode 100644 index 000000000..80a9e6284 --- /dev/null +++ b/preview/docs/health-check/index.html @@ -0,0 +1,62 @@ + + + + + + + + + Health Check | NotifyBC + + + + +

    Health Check

    Health status of NotifyBC can be obtained by querying /health API end point. For example

    $ curl -s http://localhost:3000/api/health | jq
    +{
    +  "status": "ok",
    +  "info": {
    +    "MongoDB": {
    +      "status": "up"
    +    },
    +    "config": {
    +      "status": "up",
    +      "count": 2
    +    },
    +    "redis": {
    +      "status": "up"
    +    }
    +  },
    +  "error": {},
    +  "details": {
    +    "MongoDB": {
    +      "status": "up"
    +    },
    +    "config": {
    +      "status": "up",
    +      "count": 2
    +    },
    +    "redis": {
    +      "status": "up"
    +    }
    +  }
    +}
    +

    If overall health status is OK, the HTTP response code is 200, otherwise 503. The response payload shows status of following indicators and health criteria

    1. MongoDB - MongoDB must be reachable
    2. config - There must be at least 2 items in MongoDB configuration collection
    3. Redis - Redis must be reachable if configured

    /health API end point is also reachable in API Explorer of NotifyBC web console.

    + + + diff --git a/preview/docs/index.html b/preview/docs/index.html new file mode 100644 index 000000000..cd56a3638 --- /dev/null +++ b/preview/docs/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Welcome | NotifyBC + + + + +

    Welcome

    This site aims to be a comprehensive guide to NotifyBC. We’ll cover topics such as getting your instance up and running, interacting with browser or other server components, deployment, and give you some advice on participating in the future development of NotifyBC itself.

    Helpful Hints

    Throughout this guide there are a number of small-but-handy pieces of information that can make using NotifyBC easier, more interesting, and less hazardous. Here’s what to look out for.

    General information

    These are tips and tricks that will help you become a NotifyBC wizard!

    Important information

    These are tidbits you might want to keep in mind.

    Warnings

    Be aware of these messages if you wish to avoid disaster.

    If you come across anything along the way that we haven’t covered, or if you know of a tip you think others would find handy, please file an issueopen in new window and we’ll see about including it in this guide.

    + + + diff --git a/preview/docs/installation/index.html b/preview/docs/installation/index.html new file mode 100644 index 000000000..fba9ba83d --- /dev/null +++ b/preview/docs/installation/index.html @@ -0,0 +1,147 @@ + + + + + + + + + Installation | NotifyBC + + + + +

    Installation

    NotifyBC can be installed in 3 ways:

    1. Deploy locally from Source Code
    2. Deploy to Kubernetes
    3. Deploy Docker Container

    For the purpose of evaluation, both source code and docker container will do. For production, the recommendation is one of

    • deploying to Kubernetes
    • setting up a load balanced app cluster from source code build, backed by MongoDB.

    To setup a development environment in order to contribute to NotifyBC, installing from source code is preferred.

    Deploy locally from Source Code

    System Requirements

    • Software
    • Services
      • MongoDB with replica set, required for production
      • A standard SMTP server to deliver outgoing email, required for production if email is enabled.
      • A tcp proxy server such as nginx stream proxyopen in new window if list-unsubscribe by email is needed and NotifyBC server cannot expose port 25 to internet
      • A SMS service provider if needs to enable SMS channel. The supported service providers are
        • Twilio (default)
        • Swift
      • Redis v6, required if email or sms throttling is enabled
      • SiteMinder, if needs SiteMinder authentication
      • An OIDC provider, if needs OIDC authentication
    • Network and Permissions
      • Minimum runtime firewall requirements:
        • outbound to your ISP DNS server
        • outbound to any on port 80 and 443 in order to run build scripts and send SMS messages
        • outbound to any on SMTP port 25 if using direct mail; for SMTP relay, outbound to your configured SMTP server and port only
        • inbound to listening port (3000 by default) from other authorized server ips
        • if NotifyBC instance will handle anonymous subscription from client browser, the listening port should be open to internet either directly or indirectly through a reverse proxy; If NotifyBC instance will only handle SiteMinder authenticated webapp requests, the listening port should NOT be open to internet. Instead, it should only open to SiteMinder web agent reverse proxy.
      • If list-unsubscribe by email is needed, then one of the following must be met
        • NotifyBC can bind to port 25 opening to internet
        • a tcp proxy server of which port 25 is open to internet. This proxy server can reach NotifyBC on a tcp port.

    Installation

    Run following commands

    git clone https://github.com/bcgov/NotifyBC.git
    +cd NotifyBC
    +npm i && npm run build
    +npm run start
    +

    If successful, you will see following output

    ...
    +Server is running at http://0.0.0.0:3000
    +

    Now open http://localhost:3000open in new window. The page displays NotifyBC Web Console.

    The above commands installs the main version, i.e. main branch tip of NotifyBC GitHub repository. To install a specific version, say v2.1.0, run

     git checkout tags/v2.1.0 -b v2.1.0
    +

    after cd NotifyBC. A list of versions can be found hereopen in new window.

    install from behind firewall

    If you want to install on a server behind firewall which restricts internet connection, you can work around the firewall as long as you have access to a http(s) forward proxy server. Assuming the proxy server is http://my_proxy:8080 which proxies both http and https requests, to use it:

    • For Linux

      export http_proxy=http://my_proxy:8080
      +export https_proxy=http://my_proxy:8080
      +git config --global url."https://".insteadOf git://
      +
    • For Windows

      git config --global http.proxy http://my_proxy:8080
      +git config --global url."https://".insteadOf git://
      +npm config set proxy http://my_proxy:8080
      +

    Install Windows Service

    After get the app running interactively, if your server is Windows and you want to install the app as a Windows service, run

    npm install -g node-windows
    +npm link node-windows
    +node windows-service.js
    +

    This will create and start service notifyBC. To change service name, modify file windows-service.js before running it. See node-windowsopen in new window for other operations such as uninstalling the service.

    Deploy to Kubernetes

    NotifyBC provides a container packageopen in new window in GitHub Container Registry and a Helmopen in new window chart to facilitate Deploying to Kubernetes. Azure AKS and OpenShift are the two tested platforms. Other Kubernetes platforms are likely to work subject to customizations. Before deploying to AKS, create an ingress controller open in new window.

    The deployment can be initiated from localhost or automated by CI service such as Jenkins. Regardless, at the initiator's side following software needs to be installed:

    To install,

    1. Follow your platform's instruction to login to the platform. For AKS, run az login and az aks get-credentials; for OpenShift, run oc login

    2. Run

      git clone https://github.com/bcgov/NotifyBC.git
      +cd NotifyBC
      +helm install -gf helm/platform-specific/<platform>.yaml helm
      +

      replace <platform> with openshift or aks depending on your platform.

      The above commands create following artifacts:

      • 1 stateful set of 3 pods running a MongoDB replicaset
      • 1 stateful set of 3 pods running a Redis sentinel
      • 2 deployments - notify-bc-app and notify-bc-cron
      • 1 HPA - notify-bc-cron
      • 5 services - notify-bc, notify-bc-smtp, mongodb-headless, redis and redis-headless
      • 3 PVCs each for one MongoDB pod
      • 3 service accounts - notify-bc, mongodb and redis
      • a few config maps, most importantly notify-bc
      • a few secrets, most importantly mongodb and redis, containing credentials for Mongodb and Redis respectively
      • On AKS,
        • a notify-bc ingress
      • On OpenShift,
        • 2 routes - notify-bc-web and notify-bc-smtp

    To upgrade,

    helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml --set mongodb.auth.rootPassword=<mongodb-root-password> --set mongodb.auth.replicaSetKey=<mongodb-replica-set-key> --set mongodb.auth.password=<mongodb-password> helm
    +

    replace <release-name> with installed helm release name and <platform> with openshift or aks depending on your platform. MongoDB credentials <mongodb-root-password>, <mongodb-replica-set-key> and <mongodb-password> can be found in secret <release-name>-mongodb. It is recommended to specify mongodb credentials in a file rather than command line. See Customizations below.

    To uninstall,

    helm uninstall <release-name>
    +

    replace <release-name> with installed helm release name.

    Customizations

    Various customizations can be made to chart. Some are platform dependent. To customize, first create a file with extension .local.yaml. The rest of the document assumes the file is helm/values.local.yaml. Then add customized parameters to the file. See helm/values.yaml and Bitnami MongoDB chart readmeopen in new window for customizable parameters. Parameters in helm/values.local.yaml overrides corresponding ones in helm/values.yaml. In particular, parameters under mongodb of helm/values.local.yaml overrides Bitnami MongoDB chart parameters.

    To apply customizations, add -f helm/values.local.yaml to the helm command after -f helm/platform-specific/<platform>.yaml. For example, to install chart with customization on OpenShift,

    helm install -gf helm/platform-specific/openshift.yaml -f helm/values.local.yaml helm
    +

    to upgrade an existing release with customization on OpenShift,

    helm upgrade <release-name> -f helm/platform-specific/openshift.yaml -f helm/values.local.yaml helm
    +

    Backup helm/values.local.yaml

    Backup helm/values.local.yaml to a private secured SCM is highly recommended, especially for production environment.

    Following are some common customizations

    • Update config.local.js in ConfigMap, for example to define httpHost

      # in file helm/values.local.yaml
      +configMap:
      +  config.local.js: |-
      +    module.exports = {
      +      httpHost: 'https://myNotifyBC.myOrg.com',
      +    }
      +
    • Set hostname on AKS,

      # in file helm/values.local.yaml
      +ingress:
      +  hosts:
      +    - host: myNotifyBC.myOrg.com
      +      paths:
      +        - path: /
      +
    • Use Let's Encrypt on AKSopen in new window. After following the instructions in the link, add following ingress customizations to file helm/values.local.yaml

      # in file helm/values.local.yaml
      +ingress:
      +  annotations:
      +    cert-manager.io/cluster-issuer: letsencrypt
      +  tls:
      +    - secretName: tls-secret
      +      hosts:
      +        - notify-bc.local
      +
    • Route host names on Openshift are by default auto-generated. To set to fixed values

      # in file helm/values.local.yaml
      +route:
      +  web:
      +    host: 'myNotifyBC.myOrg.com'
      +  smtp:
      +    host: 'smtp.myNotifyBC.myOrg.com'
      +
    • Add certificates to OpenShift web route

      # in file helm/values.local.yaml
      +route:
      +  web:
      +    tls:
      +      caCertificate: |-
      +        -----BEGIN CERTIFICATE-----
      +        ...
      +        -----END CERTIFICATE-----
      +      certificate: |-
      +        -----BEGIN CERTIFICATE-----
      +        ...
      +        -----END CERTIFICATE-----
      +      insecureEdgeTerminationPolicy: Redirect
      +      key: |-
      +        -----BEGIN PRIVATE KEY-----
      +        ...
      +        -----END PRIVATE KEY-----
      +
    • MongoDb

      NotifyBC chart depends on Bitnami MongoDB chartopen in new window for MongoDB database provisioning. All documented parameters are customizable under mongodb. For example, to change architecture to standalone

      # in file helm/values.local.yaml
      +mongodb:
      +  architecture: standalone
      +

      To set credentials,

      # in file helm/values.local.yaml
      +mongodb:
      +  auth:
      +    rootPassword: <secret>
      +    replicaSetKey: <secret>
      +    passwords:
      +      - <secret>
      +

      To install a Helm chart, the above credentials can be randomly defined. To upgrade an existing release, they must match what's defined in secret <release-name>-mongodb.

    • Redis

      NotifyBC chart depends on Bitnami Redis chartopen in new window for Redis provisioning. All documented parameters are customizable under redis. For example, to set credential

      # in file helm/values.local.yaml
      +redis:
      +  auth:
      +    password: <secret>
      +

      To install a Helm chart, the above credential can be randomly defined. To upgrade an existing release, It must match what's defined in secret <release-name>-redis.

    • Both Bitnami MongoDB and Redis use Docker Hub for docker registry. Rate limit imposed by Docker Hub can cause runtime problems. If your organization has JFrog artifactory, you can change the registry

    # in file helm/values.local.yaml
    +global:
    +  imageRegistry: <artifactory.myOrg.com>
    +  imagePullSecrets:
    +    - <docker-pull-secret>
    +

    The above settings assume you have setup secret <docker-pull-secret> to access <artifactory.myOrg.com>. The secret can be created using kubectlopen in new window.

    • Enable scheduled MongoDB backup CronJob

      # in file helm/values.local.yaml
      +cronJob:
      +  enabled: true
      +  schedule: '1 0 * * *'
      +  retentionDays: 7
      +  timeZone: UTC
      +  persistence:
      +    size: 5Gi
      +

      where

      • enabled: whether to enable the MongoDB backup CronJob or not; default to false
      • schedule: the Unix crontab schedule; default to '1 0 * * *' which runs daily at 12:01AM
      • retentionDays: how many days the backup is retained; default to 7
      • timeZone: the Unix TZ environment variable; default to UTC
      • persistence size: size of PVC; default to 5Gi

      The CronJob backs up MongoDB to a PVC named after the chart with suffix -cronjob-mongodb-backup and purges backups that are older than retentionDays.

      To facilitate restoration, mount the PVC to MongoDB pod

      # in file helm/values.local.yaml
      +mongodb:
      +  extraVolumes:
      +    - name: export
      +      persistentVolumeClaim:
      +        claimName: <PVC_NAME>
      +  extraVolumeMounts:
      +    - name: export
      +      mountPath: /export
      +      readOnly: true
      +

      Restoration can then be achieved by running in MongoDB pod

      mongorestore -u "$MONGODB_EXTRA_USERNAMES" -p"$MONGODB_EXTRA_PASSWORDS" \
      +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_EXTRA_DATABASES --gzip --drop \
      +--archive=/export/<mongodb-backup-YYMMDD-hhmmss.gz>
      +
    • NotifyBC image tag defaults to appVersion in file helm/Chart.yaml. To change to latest, i.e. tip of the main branch,

      # in file helm/values.local.yaml
      +image:
      +  tag: latest
      +
    • Enable autoscaling for app pod

      # in file helm/values.local.yaml
      +autoscaling:
      +  enabled: true
      +

    Deploy Docker Container

    If you have git and Docker installed, you can run following command to deploy NotifyBC Docker container:

    docker run --platform linux/amd64 --rm -dp 3000:3000 ghcr.io/bcgov/notify-bc
    +# open http://localhost:3000
    +

    If successful, similar output is displayed as in source code installation.

    + + + diff --git a/preview/docs/memory-dump/index.html b/preview/docs/memory-dump/index.html new file mode 100644 index 000000000..5ba8cf28e --- /dev/null +++ b/preview/docs/memory-dump/index.html @@ -0,0 +1,35 @@ + + + + + + + + + Memory Dump | NotifyBC + + + + +

    Memory Dump

    Super-admin can get a memory dump of NotifyBC instance by querying /memory API end point. For example

    $ curl -s http://localhost:3000/api/memory
    +Heap.20240513.114015.22037.0.001.heapsnapshot
    +

    The output is the file name of the memory dump. The dump file can be loaded by, for example, Chrome DevTools.

    How to get memory dump of a particular node?

    If you call /memory from a client-facing URL end point, which is usually load-balanced, the memory dump occurs only on node handling your request. To perform it on the node you want to troubleshoot, in particular the master node, run the command from the node. Make sure 127.0.0.1 is in adminIps.

    + + + diff --git a/preview/docs/overview/index.html b/preview/docs/overview/index.html new file mode 100644 index 000000000..92dd8104b --- /dev/null +++ b/preview/docs/overview/index.html @@ -0,0 +1,33 @@ + + + + + + + + + Overview | NotifyBC + + + + +

    Overview

    NotifyBC is a general purpose API Server to manage subscriptions and dispatch notifications. It aims to implement some common backend processes of a notification service without imposing any constraints on the UI frontend, nor impeding other server components' functionality. This is achieved by interacting with user browser and other server components through RESTful API and other standard protocols in a loosely coupled way.

    Features

    NotifyBC facilitates both anonymous and authentication-enabled secure webapps implementing notification feature. A NotifyBC server instance supports multiple notification services. A service is a topic of interest that user wants to receive updates. It is used as the partition of notification messages and user subscriptions. A user may subscribe to a service in multiple push delivery channels allowed. A user may subscribe to multiple services. In-app pull notification doesn't require subscription as it's not intrusive to user.

    notification

    • both in-app pull notifications (a.k.a. messages or alerts) and push notifications
    • multiple push notifications delivery channels
      • email
      • sms
    • unicast and broadcast message types
    • future-dated notifications
    • for in-app pull notifications
      • support read and deleted message states
      • message expiration
      • deleted messages are not deleted immediately for auditing and recovery purposes
    • for broadcast push notifications
      • allow both sync and async POST API calls. For async API call, an optional callback url is supported
      • can be auto-generated from RSS feeds
      • allow user to specify filter rules evaluated against data contained in the notification
      • allow sender to specify filter rules evaluated against data contained in the subscription
      • allow application developer to create custom filter functions used by the filter rules mentioned above

    subscription and un-subscription

    • verify the ownership of push notification subscription channel:
      • generates confirmation code based on a regex input
      • send confirmation request to unconfirmed subscription channel
      • verify confirmation code
    • generate random un-subscription code
    • send acknowledgement message after un-subscription for anonymous subscribers
    • bulk unsubscription
    • list-unsubscribe by email
    • track bounces and unsubscribe the recipient from all subscriptions when hard bounce count exceeds threshold
    • sms user can unsubscribe by replying a shortcode keyword with Swift sms provider

    mail merge

    Strings in notification or subscription message that are enclosed between curly braces { } are called tokens, also known as placeholders. Tokens are replaced based on the context of notification or subscription when dispatching the message. To avoid treating a string between curly braces as a token, escape the curly braces with backslash \. For example \{i_am_not_a_token\} is not a token. It will be rendered as {i_am_not_a_token}.

    Tokens whose names are predetermined by NotifyBC are called static tokens; otherwise they are called dynamic tokens.

    static tokens

    NotifyBC recognizes following case-insensitive static tokens. Most of the names are self-explanatory.

    • {subscription_confirmation_url}
    • {subscription_confirmation_code}
    • {service_name}
    • {http_host} - http host in the form http(s): //<host_name>:<port>. The value is obtained from the http request that triggers the message
    • {rest_api_root} - REST API URL path prefix
    • {subscription_id}
    • anonymous unsubscription related tokens
      • {unsubscription_url}
      • {unsubscription_all_url} - url to unsubscribe all services the user has subscribed on this NotifyBC instance
      • {unsubscription_code}
      • {unsubscription_reversion_url}
      • {unsubscription_service_names} - includes {service_name} and additional services user has unsubscribed, prefixed with conditionally pluralized word service.

    dynamic tokens

    Dynamic tokens are replaced with correspondingly named sub-field of data field in the notification or subscription if exist. Qualify token name with notification:: or subscription:: to indicate the source of substitution. If token name is not qualified, then both notification and subscription are checked, with notification taking precedence. Nested and indexed sub-fields are supported.

    Examples

    • {notification::description} is replaced with field data.description of the notification if exist
    • {subscription::gender} is replaced with field data.gender of the subscription if exist
    • {addresses[0].city} is replaced with field data.addresses[0].city of the notification if exist; otherwise is replaced with field data.addresses[0].city of the subscription if exist
    • {nonexistingDataField} is unreplaced if neither notification nor subscription contains data.nonexistingDataField

    As exception, in order to prevent spamming by unconfirmed subscribers, dynamic tokens in subscription confirmation request message and duplicated subscription message are not replaced with subscription data, for example {subscription::...} tokens are left unchanged.

    Notification by RSS feeds relies on dynamic token

    A notification created by RSS feeds relies on dynamic token to supply the context to message template. In this case the data field contains the RSS item.

    Architecture

    Request Types

    NotifyBC, designed to be a microservice, doesn't use full-blown ACL to secure API calls. Instead, it classifies incoming requests into admin and user types. The key difference is while both admin and user can subscribe to notifications, only admin can post notifications.

    Each type has two subtypes based on following criteria

    • super-admin, if the request meets both of the following two requirements

      1. The request carries one of the following two attributes

        • the source ip is in the admin ip list
        • has a client certificate that is signed using NotifyBC server certificate. See Client certificate authentication on how to sign.
      2. The request doesn't contain any of following case insensitive HTTP headers, with the first three being SiteMinder headers

        • sm_universalid
        • sm_user
        • smgov_userdisplayname
        • is_anonymous
    • admin, if the request is not super-admin and meets one of the following criteria

      • has a valid access token associated with an builtin admin user created and logged in using the administrator api, and the request doesn't contain any HTTP headers listed above
      • has a valid OIDC access token containing customizable admin profile attributes

      access token disambiguation

      Here the term access token has been used to refer two different things

      1. the token associated with a builtin admin user
      2. the token generated by OIDC provider.

      To reduce confusion, throughout the documentation the former is called access token and the latter is called OIDC access token.

    • authenticated user, if the request is neither super-admin nor admin, and meets one fo the following criteria

      • contains any of the 3 SiteMinder headers listed above, and comes from either trusted SiteMinder proxy or admin ip list
      • contains a valid OIDC access token
    • anonymous user, if the request doesn't meet any of the above criteria

    The only extra privileges that a super-admin has over admin are that super-admin can perform CRUD operations on configuration, bounce and administrator entities through REST API. In the remaining docs, when no further distinction is necessary, an admin request refers to both super-admin and admin request; a user request refers to both authenticated and anonymous request.

    An admin request carries full authorization whereas user request has limited access. For example, a user request is not allowed to

    • send notification
    • bypass the delivery channel confirmation process when subscribing to a service
    • retrieve push notifications through API (can only receive notification from push notification channel such as email)
    • retrieve in-app notifications that is not targeted to the current user

    The result of an API call to the same end point may differ depending on the request type. For example, the call GET /notifications without a filter will return all notifications to all users for an admin request, but only non-deleted, non-expired in-app notifications for authenticated user request, and forbidden for anonymous user request. Sometimes it is desirable for a request from admin ip list, which would normally be admin request, to be voluntarily downgraded to user request in order to take advantage of predefined filters such as the ones described above. This can be achieved by adding one of the HTTP headers listed above to the request. This is also why admin request is not determined by ip or token alone.

    The way NotifyBC interacts with other components is diagrammed below. architecture diagram

    Authentication Strategies

    API requests to NotifyBC can be either anonymous or authenticated. As alluded in Request Types above, NotifyBC supports following authentication strategies

    1. ip whitelisting
    2. client certificate
    3. access token associated with an builtin admin user
    4. OpenID Connect (OIDC)
    5. CA SiteMinder

    Authentication is performed in above order. Once a request passed an authentication strategy, the rest strategies are skipped. A request that failed all authentication strategies is anonymous.

    The mapping between authentication strategy and request type is

    AdminUser
    Super-adminadminauthenticatedanonymous
    ip whitelisting
    client certifcate
    access token
    OIDC
    SiteMinder

    Which authentication strategy to use?

    Because ip whitelist doesn't expire and client certificate usually has a relatively long expiration period (say one year), they are suitable for long-running unattended server processes such as server-side code of web apps, cron jobs, IOT sensors etc. The server processes have to be trusted because once authenticated, they have full privilege to NotifyBC. Usually the server processes and NotifyBC instance are in the same administrative domain, i.e. managed by the same admin group of an organization.

    By contrast, OIDC and SiteMinder use short-lived tokens or session cookies. Therefore they are only suitable for interactive user sessions.

    Access token associated with an builtin admin user should be avoided whenever possible.

    Here are some common scenarios and recommendations

    • For server-side code of web apps

      • use OIDC if the web app is OIDC enabled and user requests can be proxied to NotifyBC by web app; otherwise
      • use ip whitelisting if obtaining ip is feasible; otherwise
      • use client certificate (requires a little more config than ip whitelisting)
    • For front-end browser-based web apps such as SPAs

      • use OIDC
    • For server apps that send requests spontaneously such as IOT sensors, cron jobs

      • use ip whitelisting if obtaining ip is feasible; otherwise
      • client certificate
    • If NotifyBC is ued by a SiteMinder protected web apps and NotifyBC is also protected by SiteMinder

      • use SiteMinder

    Application Framework

    NotifyBC is created on NestJSopen in new window. Contributors to source code of NotifyBC should be familiar with NestJS. NestJS Docsopen in new window serves a good complement to this documentation.

    + + + diff --git a/preview/docs/quickstart/index.html b/preview/docs/quickstart/index.html new file mode 100644 index 000000000..76e661b68 --- /dev/null +++ b/preview/docs/quickstart/index.html @@ -0,0 +1,38 @@ + + + + + + + + + Quick Start | NotifyBC + + + + + + + + diff --git a/preview/docs/shared/filterQueryParam.html b/preview/docs/shared/filterQueryParam.html new file mode 100644 index 000000000..ea107fdd0 --- /dev/null +++ b/preview/docs/shared/filterQueryParam.html @@ -0,0 +1,61 @@ + + + + + + + + + NotifyBC + + + + +

    a filter containing properties where, fields, order, skip, and limit

    - parameter name: filter
    +- required: false
    +- parameter type: query
    +- data type: object
    +
    +The filter can be expressed as either
    +
    +  1. URL-encoded stringified JSON object (see example below); or
    +  2. in the format supported by [qs](https://github.com/ljharb/qs), for example `?filter[where][created][$gte]="2023-01-01"&filter[where][created][$lt]="2024-01-01"`
    +
    +Regardless, the filter will have to be parsed into a JSON object conforming to
    +
    +```json
    +{
    +    "where": {...},
    +    "fields": ...,
    +    "order": ...,
    +    "skip": ...,
    +    "limit": ...,
    +}
    +```
    +
    +All properties are optional. The syntax for each property is documented, respectively
    +- for *where* , see MongoDB [Query Documents](https://www.mongodb.com/docs/manual/tutorial/query-documents/)
    +- for *fields* , see Mongoose [select](https://mongoosejs.com/docs/api/query.html#Query.prototype.select())
    +- for *order*, see Mongoose [sort](https://mongoosejs.com/docs/api/query.html#Query.prototype.sort())
    +- for *skip*, see MongoDB [cursor.skip](https://www.mongodb.com/docs/manual/reference/method/cursor.skip/)
    +- for *limit*, see MongoDB [cursor.limit](https://www.mongodb.com/docs/manual/reference/method/cursor.limit/)
    +
    + + + diff --git a/preview/docs/shared/filterQueryParamCode.html b/preview/docs/shared/filterQueryParamCode.html new file mode 100644 index 000000000..9ee0e2efb --- /dev/null +++ b/preview/docs/shared/filterQueryParamCode.html @@ -0,0 +1,33 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/docs/shared/filterQueryParamExample.html b/preview/docs/shared/filterQueryParamExample.html new file mode 100644 index 000000000..e0c42f4b3 --- /dev/null +++ b/preview/docs/shared/filterQueryParamExample.html @@ -0,0 +1,43 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/docs/shared/jmespathFilter.html b/preview/docs/shared/jmespathFilter.html new file mode 100644 index 000000000..fe60618cc --- /dev/null +++ b/preview/docs/shared/jmespathFilter.html @@ -0,0 +1,50 @@ + + + + + + + + + NotifyBC + + + + +
      <div class="description">a string conforming to jmespath <a href="http://jmespath.org/specification.html#filter-expressions">filter expressions syntax</a> after the question mark (?). The filter is matched against the <i><a href="../api-subscription#data">data</a></i> field of the subscription. Examples of filter
    +    <ul>
    +      <li>simple <br/>
    +        <i>province == 'BC'</i>
    +      </li>
    +      <li>calling jmespath's <a href="http://jmespath.org/specification.html#built-in-functions">built-in functions</a> <br/>
    +        <i>contains(province,'B')</i>
    +      </li>
    +      <li>calling <a href="../config-notification/#broadcast-push-notification-custom-filter-functions">custom filter functions</a><br/>
    +        <i>contains_ci(province,'b')</i>
    +      </li>
    +      <li>compound <br/>
    +        <i>(contains(province,'BC') || contains_ci(province,'b')) && city == 'Victoria' </i>
    +      </li>
    +    </ul>
    +    All of above filters will match data object <i>{"province": "BC", "city": "Victoria"}</i>
    +  </div>
    +
    + + + diff --git a/preview/docs/shared/throttle.html b/preview/docs/shared/throttle.html new file mode 100644 index 000000000..6d4803eb6 --- /dev/null +++ b/preview/docs/shared/throttle.html @@ -0,0 +1,33 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/docs/shared/whereQueryParam.html b/preview/docs/shared/whereQueryParam.html new file mode 100644 index 000000000..af9c2f196 --- /dev/null +++ b/preview/docs/shared/whereQueryParam.html @@ -0,0 +1,42 @@ + + + + + + + + + NotifyBC + + + + +

    a where query parameter with value conforming to MongoDB Query Documentsopen in new window

    - parameter name: where
    +- required: false
    +- parameter type: query
    +- data type: object
    +
    +The value can be expressed as either
    +
    +  1. URL-encoded stringified JSON object (see example below); or
    +  2. in the format supported by [qs](https://github.com/ljharb/qs), for example `?where[created][$gte]="2023-01-01"&where[created][$lt]="2024-01-01"`
    +
    + + + diff --git a/preview/docs/shared/whereQueryParamCode.html b/preview/docs/shared/whereQueryParamCode.html new file mode 100644 index 000000000..338138621 --- /dev/null +++ b/preview/docs/shared/whereQueryParamCode.html @@ -0,0 +1,33 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/docs/shared/whereQueryParamExample.html b/preview/docs/shared/whereQueryParamExample.html new file mode 100644 index 000000000..650e8b57a --- /dev/null +++ b/preview/docs/shared/whereQueryParamExample.html @@ -0,0 +1,41 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/docs/upgrade/index.html b/preview/docs/upgrade/index.html new file mode 100644 index 000000000..f22c6e2d0 --- /dev/null +++ b/preview/docs/upgrade/index.html @@ -0,0 +1,127 @@ + + + + + + + + + Upgrade Guide | NotifyBC + + + + +

    Upgrade Guide

    Major version can only be upgraded incrementally from immediate previous major version, i.e. from N to N+1.

    v1 to v2

    Upgrading NotifyBC from v1 to v2 involves two steps

    1. Update your client code if needed
    2. Upgrade NotifyBC server

    Update your client code

    NotifyBC v2 introduced backward incompatible API changes documented in the rest of this section. If your client code will be impacted by the changes, update your code to address the incompatibility first.

    Query parameter array syntax

    In v1 array can be specified in query parameter using two formats

    1. by enclosing array elements in square brackets such as &additionalServices=["s1","s2] in one query parameter
    2. by repeating the query parameters, for example &additionalServices=s1&additionalServices=s2

    In v2 only the latter format is supported.

    Date-Time fields

    In v1 date-time fields can be specified in date-only string such as 2020-01-01. In v2 the field must be specified in ISO 8601 extended format such as 2020-01-01T00:00:00Z.

    Return status codes

    HTTP response code of success calls to following APIs are changed from 200 to 204

    Administrator API

    Upgrade NotifyBC server

    The procedure to upgrade from v1 to v2 depends on how v1 was installed.

    Source-code Installation

    1. Stop NotifyBC
    2. Backup app root and database!
    3. Make sure current branch is tracking correct remote branch
      git remote set-url origin https://github.com/bcgov/NotifyBC.git
      +git branch -u origin/main
      +
    4. Make a note of any extra packages added to package.json
    5. Run git pull && git checkout tags/v2.x.x from app root, replace v2.x.x with a v2 release, preferably latest, found in GitHub such as v2.9.0.
    6. Make sure version property in package.json is 2.x.x
    7. Add back extra packages noted in step 4
    8. Move server/config.(local|dev|production).(js|json) to src/ if exists
    9. Move server/datasources.(local|dev|production).(js|json) to src/datasources/db.datasource.(local|dev|production).(js|json) if exists. Notice the file name has changed.
    10. Move server/middleware.*.(js|json) to src/ if exists. Reorganize top level properties to all or apiOnly, where all applies to all requests including web console and apiOnly applies to API requests only. For example, given
    module.exports = {
    +  initial: {
    +    compression: {},
    +  },
    +  'routes:before': {
    +    morgan: {
    +      enabled: false,
    +    },
    +  },
    +};
    +

    if compression middleware will be applied to all requests and morgan will be applied to API requests only, then change the file to

    module.exports = {
    +  all: {
    +    compression: {},
    +  },
    +  apiOnly: {
    +    morgan: {
    +      enabled: false,
    +    },
    +  },
    +};
    +
    1. Run
    npm i && npm run build
    +
    1. Start server by running npm run start or Windows Service

    OpenShift Installation

    1. Run

      git clone https://github.com/bcgov/NotifyBC.git
      +cd NotifyBC
      +
    2. Run

      oc delete bc/notify-bc
      +oc process -f .openshift-templates/notify-bc-build.yml | oc create -f-
      +

      ignore AlreadyExists errors

    3. Follow OpenShift Build

    4. For each environment,

      1. run

        oc project <yourprojectname-<env>>
        +oc delete dc/notify-bc-app dc/notify-bc-cron
        +oc process -f .openshift-templates/notify-bc.yml | oc create -f-
        +

        ignore AlreadyExists errors

      2. copy value of environment variable MONGODB_USER from mongodb deployment config to the same environment variable of deployment config notify-bc-app and notify-bc-cron, replacing existing value

      3. remove middleware.local.json from configMap notify-bc

      4. add middleware.local.js to configMap notify-bc with following content

        module.exports = {
        +  apiOnly: {
        +    morgan: {
        +      enabled: false,
        +    },
        +  },
        +};
        +
      5. Follow OpenShift Deploy or Change Propagation to tag image

    OpenShift template to Helm

    Upgrading NotifyBC on OpenShift created from OpenShift template to Helm involves 2 steps

    1. Customize and Install Helm chart
    2. Migrate MongoDB data

    Customize and install Helm chart

    Follow customizations to create file helm/values.local.yaml containing customized configs such as

    • notify-bc configMap
    • web route host name and certificates

    Then run helm install with documented arguments to install a release.

    Migrate MongoDB data

    1. backup data from source

      oc exec -i <mongodb-pod> -- bash -c 'mongodump -u "$MONGODB_USER" \
      +-p "$MONGODB_PASSWORD" -d $MONGODB_DATABASE --gzip --archive' > notify-bc.gz
      +

      replace <mongodb-pod> with the mongodb pod name.

    2. restore backup to target

      cat notify-bc.gz | oc exec -i <mongodb-pod-0> -- \
      +bash -c 'mongorestore -u "$MONGODB_USERNAME" -p"$MONGODB_PASSWORD" \
      +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_DATABASE --gzip --drop --archive'
      +

      replace <mongodb-pod-0> with the first pod name in the mongodb stateful set.

    If both source and target are in the same OpenShift cluster, the two operations can be combined into one

    oc exec -i <mongodb-pod> -- bash -c 'mongodump -u "$MONGODB_USER" \
    +-p "$MONGODB_PASSWORD" -d $MONGODB_DATABASE --gzip --archive' | \
    +oc exec -i <mongodb-pod-0> -- bash -c \
    +'mongorestore -u "$MONGODB_USERNAME" -p"$MONGODB_PASSWORD" \
    +--uri="mongodb://$K8S_SERVICE_NAME" --db $MONGODB_DATABASE --gzip --drop --archive'
    +

    v2 to v3

    v3 introduced following backward incompatible changes

    1. Changed output-only fields failedDispatches and successDispatches to dispatch.failed and dispatch.successful respectively in Notification api. If your client app depends on the fields, change accordingly.
    2. Changed config notification.logSuccessfulBroadcastDispatches to notification.guaranteedBroadcastPushDispatchProcessing and reversed default value from false to true. If you don't want NotifyBC guarantees processing all subscriptions to a broadcast push notification in a node failure resilient way, perhaps for performance reason, set the value to false in file /src/config.local.js.

    After above changes are addressed, upgrading to v3 is as simple as

    git pull
    +git checkout tags/v3.x.x
    +npm i && npm run build
    +

    or, if NotifyBC is deployed to Kubernetes using Helm.

    git pull
    +git checkout tags/v3.x.x
    +helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
    +

    Replace v3.x.x with a v3 release, preferably latest, found in GitHub such as v3.1.2.

    v3 to v4

    v4 introduced following backward incompatible changes that need to be addressed in this order

    1. The precedence of config, middleware and datasource files has been changed. Local file takes higher precedence than environment specific file. For example, for config file, the new precedence in ascending order is

      1. default file /src/config.ts
      2. environment specific file /src/config.<env>.js, where <env> is determined by environment variable NODE_ENV
      3. local file /src/config.local.js

      To upgrade, if you have environment specific file, merge its content into the local file, then delete it.

    2. Config smtp is changed to email.smtp. See SMTP for example.

    3. Config inboundSmtpServer is changed to email.inboundSmtpServer. See Inbound SMTP Server for example.

    4. Config email.inboundSmtpServer.bounce is changed to email.bounce. See Bounce for example.

    5. Config notification.handleBounce is changed to email.bounce.enabled.

    6. Config notification.handleListUnsubscribeByEmail is changed to email.listUnsubscribeByEmail.enabled. See List-unsubscribe by Email for example.

    7. Config smsServiceProvider is changed to sms.provider. See Provider for example.

    8. SMS service provider specific settings defined in config sms are changed to sms.providerSettings. See Provider Settings for example. The config object sms now encapsulates all SMS configs - provider, providerSettings and throttle.

    9. Legacy config subscription.unsubscriptionEmailDomain is removed. If you have it defined in your file /src/config.local.js, replace with email.inboundSmtpServer.domain.

    10. Helm chart added Redis that requires authentication by default. Create a new password in helm/values.local.yaml to facilitate upgrading

    # in file helm/values.local.yaml
    +redis:
    +  auth:
    +    password: '<secret>'
    +

    After above changes are addressed, upgrading to v4 is as simple as

    git pull
    +git checkout tags/v4.x.x
    +npm i && npm run build
    +

    or, if NotifyBC is deployed to Kubernetes using Helm.

    git pull
    +git checkout tags/v4.x.x
    +helm upgrade <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
    +

    Replace v4.x.x with a v4 release, preferably latest, found in GitHub such as v4.0.0.

    v4 to v5

    v5 introduced following backward incompatible changes

    1. Replica set is required for MongoDB. If you deployed NotifyBC using Helm, replica set is already enabled by default.

    2. If you use default in-memory database, data in server/database/data.json will not be migrated automatically. Manually migrate if necessary.

    3. Update file src/datasources/db.datasource.local.[json|js|ts]

      1. rename url property to uri
      2. for other properties, instead of following LoopBack MongoDB data sourceopen in new window, follow Mongoose connection optionsopen in new window. In particular, host, port and database properties are no longer supported. Use uri instead.

      For example, change

      {
      +  "name": "db",
      +  "connector": "mongodb",
      +  "url": "mongodb://127.0.0.1:27017/notifyBC"
      +}
      +

      to

      {
      +  "uri": "mongodb://127.0.0.1:27017/notifyBC"
      +}
      +

      If you deployed NotifyBC using Helm, this is taken care of.

    4. API querying operators have changed. Replace following Loopback operatorsopen in new window with corresponding MongoDB operatorsopen in new window at your client-side API call.

      Loopback operatorsMongoDB operators
      eq$eq
      and$and
      or$or
      gt, gte$gt, $gte
      lt, lte$lt, $lte
      between(no equivalent, replace with $gt, $and and $lt)
      inq, nin$in, $nin
      near$near
      neq$ne
      like, nlike(replace with $regexp)
      like, nlike, options: i(replace with $regexp)
      regexp$regex
    5. API order filter syntax has changed. Replace syntax from Loopbackopen in new window to Mongooseopen in new window at client-side API call. For example, if your client-side code generates following API call

      GET http://localhost:3000/api/configurations?filter={"order":["serviceName asc"]}
      +

      change to either

      GET http://localhost:3000/api/configurations?filter={"order":[["serviceName","asc"]]}
      +

      or

      GET http://localhost:3000/api/configurations?filter={"order":"serviceName"}
      +
    6. In MongoDB administrator collection, email has changed from case-sensitively unique to case-insensitively unique. Make sure administrator emails differ not just by case.

    7. When a subscription is created by anonymous user, the data field is preserved. In earlier versions this field is deleted.

    8. Dynamic tokens in subscription confirmation request message and duplicated subscription message are not replaced with subscription data, for example {subscription::...} tokens are left unchanged. Update the template of the two messages if dynamic tokens in them depends on subscription data.

    9. Inbound SMTP Server no longer accepts command line arguments or environment variables as inputs. All inputs have to be defined in config files shown in the link.

    10. If you deployed NotifyBC using Helm, change MongoDB password format in your local values yaml file from

      # in file helm/values.local.yaml
      +mongodb:
      +  auth:
      +    rootPassword: <secret>
      +    replicaSetKey: <secret>
      +    password: <secret>
      +

      to

      # in file helm/values.local.yaml
      +mongodb:
      +  auth:
      +    rootPassword: <secret>
      +    replicaSetKey: <secret>
      +    passwords:
      +      - <secret>
      +

    After above changes are addressed, to upgrade NotifyBC to v5,

    • if NotifyBC is deployed from source code, run

      git pull
      +git checkout tags/v5.x.x
      +npm i && npm run build
      +
    • if NotifyBC is deployed to Kubernetes using Helm,

      1. backup MongoDB database
      2. run
        helm uninstall <release-name>
        +
        Replace <release-name> with installed helm release name
      3. delete PVCs used by MongoDB stateful set
      4. run
        git pull
        +git checkout tags/v5.x.x
        +helm install <release-name> -f helm/platform-specific/<platform>.yaml -f helm/values.local.yaml helm
        +
        Replace
        • v5.x.x with a v5 release, preferably latest, found in GitHub such as v5.0.0.
        • <release-name> with installed helm release name
        • <platform> with openshift or aks depending on your platform
      5. restore MongoDB database
    + + + diff --git a/preview/docs/web-console/index.html b/preview/docs/web-console/index.html new file mode 100644 index 000000000..2746a2223 --- /dev/null +++ b/preview/docs/web-console/index.html @@ -0,0 +1,36 @@ + + + + + + + + + Web Console | NotifyBC + + + + +

    Web Console

    After installing NotifyBC, you can start exploring NotifyBC resources by opening web console, a curated GUI, at http://localhost:3000open in new window. You can further explore full-blown APIs by clicking the API explorer Swagger UI embedded in web console.

    Consult the API docs for valid inputs and expected outcome while you are exploring the APIs. Once you are familiar with the APIs, you can start writing code to call the APIs from either user browser or from a server application.

    What you see in web console and what you get from API calls depend on how your requests are authenticated.

    Ip whitelisting authentication

    The API calls you made with API explorer as well as API calls made by web console from localhost are by default authenticated as super-admin requests because localhost is in admin ip list by default. Ip whitelisting authentication status is indicated by the verified_user icon on top right corner of web console.

    To see the result of non super-admin requests, you can choose one of the following methods

    • customize admin ip list to omit localhost (127.0.0.1)
    • access web console from another ip not in the admin ip list

    Client certificate authentication

    If your ip is not in the admin ip list but you have setup a client certificate issued by NotifyBC server in browser, the API calls you made with API explorer as well as API calls made by web console are also authenticated as super-admin requests. Client certificate authentication status is indicated by the verified icon on top right corner of web console.

    Anonymous

    If you access web console from a client that is not in the admin ip list, you are by default anonymous user. Anonymous authentication status is indicated by the LOGINlogin button on top right corner of web console. Click the button to login.

    Access token authentication

    If you have not configured OIDC, the login button opens a login form. After successful login, the login button is replaced with the Access Token text field on top right corner of web console. You can edit the text field. If the new access token you entered is invalid, you are essentially logging yourself out. In such case Access Token text field is replaced by the LOGINlogin button.

    The procedure to create an admin login account is documented in Administrator API

    Tokens are not shared between API Explorer and web console

    Despite API Explorer appears to be part of web console, it is a separate application. At this point neither the access token nor the OIDC access token are shared between the two applications. You have to use API Explorer's Authorize button to authenticate even if you have logged into web console.

    OIDC authentication

    If you have configured OIDC, then the login button will direct you to OIDC provider's login page. Once login successfully, you will be redirected back to NoitfyBC web console. OIDC authentication status is indicated by the LOGOUTlogout button.

    If you passed isAdmin validation, you are admin; otherwise you are authenticated user.

    SiteMinder authentication

    To get results of a SiteMinder authenticated user, do one of the following

    • access the API via a SiteMinder proxy if you have configured SiteMinder properly
    • use a tool such as curl that allows to specify custom headers, and supply SiteMinder header SM_USER:
    curl -X GET --header "Accept: application/json" \
    +    --header "SM_USER: foo" \
    +    "http://localhost:3000/api/notifications"
    +
    + + + diff --git a/preview/docs/what's-new/index.html b/preview/docs/what's-new/index.html new file mode 100644 index 000000000..79ae72929 --- /dev/null +++ b/preview/docs/what's-new/index.html @@ -0,0 +1,33 @@ + + + + + + + + + What's New | NotifyBC + + + + +

    What's New

    NotifyBC uses semantic versioningopen in new window.

    v5

    v5.1.0

    v5.0.0

    See Upgrade Guide for more information.

    • Runs on NestJS
    • Bitnami MongoDB Helm chart is updated from version 10.7.1 to 14.3.2, with corresponding MongoDB from 4.4 to 7.0.4
    • Bitnami Redis Helm chart is updated from version 14.7.2 to 16.13.2, with corresponding Redis from 6.2.4 to 6.2.7

    Why v5?

    NotifyBC was built on LoopBackopen in new window since the beginning. While Loopback is an awesome framework at the time, it is evident by 2022 Loopback is no longer actively maintained

    1. features such as GraphQL have been in experimental state for years
    2. recent commits are mostly chores rather than enhancements
    3. core developers have ceased to contribute

    To pave the way for future growth, switching platform becomes necessary. NestJS was chosen because

    1. both NestJS and Loopback are server-side Node.js frameworks
    2. NestJS has the closest feature set as Loopback. To a large extent NestJS is a superset of Loopback
    3. NestJS incorporates more technologies

    v4

    v4.1.0

    • Issue #50open in new window: Email message throttle
    • applied sms throttle to all sms messages rather than just broadcast push notification.
    • docs updates

    v4.0.0

    See v3 to v4 upgrade guide for more information.

    • Issue #48open in new window: SMS message throttle
    • Re-ordered config file precedence
    • Re-organized Email and SMS configs
    • docs updates

    v3

    v3.1.0

    • Issue #45open in new window: Reliability - Log skipped dispatches for broadcast push notifications
    • docs updates

    v3.0.0

    See v2 to v3 upgrade guide for more information.

    v2

    v2.9.0

    v2.8.0

    v2.7.0

    v2.6.0

    • Helm chart updates
    • docs updates

    v2.5.0

    v2.4.0

    • Issue #16open in new window: Support client certificate authentication
    • misc web console adjustments
    • docs updates

    v2.3.0

    • Issue #15open in new window: Support OIDC authentication for both admin and non-admin user
    • misc web console adjustments
    • docs updates

    v2.2.0

    • Issue #14open in new window: Support Administrator login, changing password, obtain access token in web console
    • misc web console adjustments
    • docs updates

    v2.1.0

    • Issue #13open in new window: Upgraded Vuetify from v0.16.9 to v2.4.3
    • misc web console adjustments
    • docs updates

    v2.0.0

    See Upgrade Guide for more information.

    • Runs on LoopBack v4
    • All code is converted to TypeScript
    • Upgraded OASopen in new window from v2 to v3
    • Docs is converted from Jekyll to VuePress

    Why v2?

    NotifyBC has been built on Node.js LoopBackopen in new window framework since 2016. LoopBack v4, which was released in 2019, is backward incompatible. To keep software stack up-to-date, unless rewriting from scratch, it is necessary to port NotifyBC to LoopBack v4. Great care has been taken to minimize upgrade effort.

    + + + diff --git a/preview/drawings/subscription sequence.vsdx b/preview/drawings/subscription sequence.vsdx new file mode 100644 index 0000000000000000000000000000000000000000..2813854174255935aa53c306372d8d3ea3022069 GIT binary patch literal 83584 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 C+3a-y literal 0 HcmV?d00001 diff --git a/preview/google5b66eb7c005753e3.html b/preview/google5b66eb7c005753e3.html new file mode 100644 index 000000000..71fce5e55 --- /dev/null +++ b/preview/google5b66eb7c005753e3.html @@ -0,0 +1 @@ +google-site-verification: google5b66eb7c005753e3.html \ No newline at end of file diff --git a/preview/help/index.html b/preview/help/index.html new file mode 100644 index 000000000..27a0c4b0e --- /dev/null +++ b/preview/help/index.html @@ -0,0 +1,33 @@ + + + + + + + + + NotifyBC + + + + + + + + diff --git a/preview/img/admin-data-models.svg b/preview/img/admin-data-models.svg new file mode 100644 index 000000000..5977a00c6 --- /dev/null +++ b/preview/img/admin-data-models.svg @@ -0,0 +1,3 @@ + + +
    These fields are not saved in database, but rather
       shown in query results when optionally included.
    * These fields are not saved in database...
    AdministratorUserCredentialAccessToken
    PKid
    FKuserCredentials*
    FKaccessTokens*
    PKidFKuserCredentials*FKac...
    PKid
    FKuserId
    PKidFKuserId
    PKid
    FKuserId
    PKidFKuserId
    Viewer does not support full SVG 1.1
    \ No newline at end of file diff --git a/preview/img/architecture.svg b/preview/img/architecture.svg new file mode 100644 index 000000000..36c2e2d74 --- /dev/null +++ b/preview/img/architecture.svg @@ -0,0 +1,3 @@ + + +
    Server apps
    Server apps
    User browser to anonymous web apps
    User browser to anon...
    User browser to SiteMinder protected web apps
    User browser to Site...
    NotifyBC API Server
    NotifyBC API Server
    admin request or
    downgraded authenticated
     user request
    admin request or...
    authenticated user request
    authenticated user request
    anonymous user request
    anonymous user request
    SiteMinder gateway
    SiteMinder gateway
    push notification channels
    push notification channels
    SMTP server
    SMTP server
    email
    email
    SMS provider
    SMS provider
    sms
    sms
    admin request or
    authenticated user request
    admin request or...
    User browser to OIDC protected web apps
    User browser to OIDC...
    Viewer does not support full SVG 1.1
    \ No newline at end of file diff --git a/preview/img/list-unsubscription.png b/preview/img/list-unsubscription.png new file mode 100644 index 0000000000000000000000000000000000000000..6b28b7e4cabaaa3f237d7d9c97030aa514c1e810 GIT binary patch literal 13590 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 + + + + + + + NotifyBC + diff --git a/preview/img/subscription-multi-service-provider.png b/preview/img/subscription-multi-service-provider.png new file mode 100644 index 0000000000000000000000000000000000000000..24d2a0dfafd539879dd093556dbec4921d4a8a82 GIT binary patch literal 28412 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

    Y7&_oVI`c4;6tvYh07qlL%k9Ds>~64h!1-YE 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 T!H>XqkU%n$iZ77j27dnmDo|xT literal 0 HcmV?d00001 diff --git a/preview/index.html b/preview/index.html new file mode 100644 index 000000000..8524a3b24 --- /dev/null +++ b/preview/index.html @@ -0,0 +1,54 @@ + + + + + + + + + NotifyBC | A versatile notification API server + + + + +

    hero

    A versatile notification API server

    Quick Start →

    Versatile

      +
    • Anonymous or authenticated subscriptions
    • +
    • Push and in-app pull notifications
    • +
    • Email and SMS push notification channels
    • +
    • Unicast and broadcast message types
    • +
    • Broadcast push notification filter rules specifiable by both sender and subscriber
    • +
    • Notification auto-gen from RSS
    • +
    +

    Non-intrusive

      +
    • Handles common backend business logic only, allowing site developer implement frontend UI using widgets native to the site +
    • +
    • Loose coupling - interacts with user browser or other server components through RESTful API +
    • +
    +

    Secure & Reliable

      +
    • Support end-to-end encryption +
    • +
    • Multiple authentication strategies including client certificate for server-server and OIDC for user-server
    • +
    • Resilient to node failures
    • +
    +
    + + +