domma-cms 0.101.6 → 0.101.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.50.2
2
+ * Domma Tools CSS v0.50.3
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-28T16:00:29.724Z
6
- * Commit: 898a769
5
+ * Built: 2026-09-28T16:16:38.439Z
6
+ * Commit: 6399d99
7
7
  */
8
8
 
9
9
  /*!
@@ -50,7 +50,7 @@
50
50
 
51
51
  .dm-btn-primary {
52
52
  background: var(--dm-primary);
53
- color: var(--dm-white);
53
+ color: var(--dm-primary-text, var(--dm-white));
54
54
  border-color: var(--dm-primary);
55
55
  }
56
56
 
@@ -60,7 +60,7 @@
60
60
 
61
61
  .dm-btn-secondary {
62
62
  background: var(--dm-secondary);
63
- color: var(--dm-white);
63
+ color: var(--dm-secondary-text, var(--dm-white));
64
64
  border-color: var(--dm-secondary);
65
65
  }
66
66
 
@@ -70,7 +70,7 @@
70
70
 
71
71
  .dm-btn-success {
72
72
  background: var(--dm-success);
73
- color: var(--dm-white);
73
+ color: var(--dm-success-text, var(--dm-white));
74
74
  border-color: var(--dm-success);
75
75
  }
76
76
 
@@ -126,7 +126,8 @@
126
126
  /* Theme Toggle Buttons */
127
127
  .dm-theme-toggle {
128
128
  display: flex;
129
- background: var(--dm-gray-200);
129
+ background: var(--dm-surface-raised, var(--dm-surface));
130
+ border: 1px solid var(--dm-border);
130
131
  border-radius: var(--dm-radius-full);
131
132
  padding: 0.25rem;
132
133
  }
@@ -149,7 +150,7 @@
149
150
 
150
151
  .dm-theme-btn.active {
151
152
  background: var(--dm-primary);
152
- color: var(--dm-white);
153
+ color: var(--dm-primary-text, var(--dm-white));
153
154
  }
154
155
 
155
156
  /* Presets */
@@ -188,7 +189,7 @@
188
189
  .dm-preset-btn.active {
189
190
  background: var(--dm-primary);
190
191
  border-color: var(--dm-primary);
191
- color: var(--dm-white);
192
+ color: var(--dm-primary-text, var(--dm-white));
192
193
  }
193
194
 
194
195
  /* Sections (Accordion) */
@@ -309,7 +310,7 @@
309
310
  flex: 1;
310
311
  height: 6px;
311
312
  border-radius: 3px;
312
- background: var(--dm-gray-300);
313
+ background: var(--dm-border);
313
314
  appearance: none;
314
315
  cursor: pointer;
315
316
  }
@@ -598,8 +599,8 @@
598
599
  align-items: center;
599
600
  justify-content: center;
600
601
  border-radius: var(--dm-radius-md);
601
- background: var(--dm-primary-light, var(--dm-primary));
602
- color: var(--dm-primary);
602
+ background: var(--dm-primary);
603
+ color: var(--dm-primary-text, var(--dm-white));
603
604
  }
604
605
 
605
606
  .qr-library-name {
@@ -743,7 +744,7 @@
743
744
 
744
745
  .qr-btn-primary {
745
746
  background: var(--dm-primary);
746
- color: var(--dm-white);
747
+ color: var(--dm-primary-text, var(--dm-white));
747
748
  border-color: var(--dm-primary);
748
749
  }
749
750
 
@@ -896,7 +897,7 @@
896
897
  display: flex;
897
898
  flex-direction: column;
898
899
  border-left: 1px solid var(--dm-border);
899
- background: var(--dm-white);
900
+ background: var(--dm-surface);
900
901
  }
901
902
 
902
903
  .qr-preview-header {
@@ -1001,7 +1002,7 @@
1001
1002
 
1002
1003
  .qr-align-btn.active {
1003
1004
  background: var(--dm-primary);
1004
- color: var(--dm-white);
1005
+ color: var(--dm-primary-text, var(--dm-white));
1005
1006
  border-color: var(--dm-primary);
1006
1007
  }
1007
1008
 
@@ -1081,7 +1082,7 @@
1081
1082
  .dm-editor-toolbar-btn:active,
1082
1083
  .dm-editor-toolbar-btn.active {
1083
1084
  background: var(--dm-primary);
1084
- color: var(--dm-white);
1085
+ color: var(--dm-primary-text, var(--dm-white));
1085
1086
  }
1086
1087
 
1087
1088
  /* Content Areas */
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.50.2
2
+ * Domma Tools v0.50.3
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-28T16:00:15.087Z
6
- * Commit: 898a769
5
+ * Built: 2026-09-28T16:16:25.037Z
6
+ * Commit: 6399d99
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
@@ -1 +1 @@
1
- export const SYSTEM_SLUGS=["roles","user-profiles"],FILTERS=["all","own","builtin","plugin"];const u="core";export function slugify(n){return String(n??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export function kindOf(n){return n?.builtIn===!0||n?.preset===!0||SYSTEM_SLUGS.includes(n?.slug)?"builtin":n?.plugin?"plugin":"own"}export const projectOf=n=>n?.meta?.project||u;export function arrangeCollections(n,{filter:e="all",query:t="",project:r=null}={}){const i=String(t).trim().toLowerCase();return[...n||[]].filter(o=>!r||projectOf(o)===r).filter(o=>e==="all"||kindOf(o)===e).filter(o=>!i||[o.title,o.slug,o.description,o.plugin].some(l=>String(l||"").toLowerCase().includes(i))).sort((o,l)=>String(o.title||o.slug).localeCompare(String(l.title||l.slug)))}export function kindCounts(n,{project:e=null}={}){const t=(n||[]).filter(i=>!e||projectOf(i)===e),r={all:t.length,own:0,builtin:0,plugin:0};for(const i of t)r[kindOf(i)]++;return r}const a=(n,e,t=`${e}s`)=>`${n} ${n===1?e:t}`;export function collectionRow(n){const e=kindOf(n),t=Array.isArray(n.fields)?n.fields.length:0,r=Number(n.entryCount)||0,i=n.storage?.adapter==="mongodb",o=n.meta?.project||"";return{slug:n.slug,title:n.title||n.slug,meta:[n.slug,a(t,"field"),o?`project ${o}`:""].filter(Boolean).join(" \xB7 "),entries:r,entriesText:r.toLocaleString("en-GB"),mongo:i,mongoTitle:i?`MongoDB - connection "${n.storage.connection||"default"}"`:"",builtin:e==="builtin",bundled:e!=="builtin"&&n.bundled===!0,plugin:n.plugin||"",pluginTitle:n.plugin?`Managed by the ${n.plugin} plugin`:"",canDelete:e!=="builtin",hasForm:!SYSTEM_SLUGS.includes(n.slug)}}export function checkNewCollection(n,e=[]){const t=String(n??"").trim(),r=slugify(t);return t?r?e.includes(r)?{slug:r,error:`"${r}" is already a collection.`}:{slug:r,error:""}:{slug:r,error:"The title needs at least one letter or number."}:{slug:"",error:""}}let s=0;export function connectionRows(n){return Object.entries(n||{}).filter(([e])=>e!=="_comment").map(([e,t])=>({id:`c${++s}`,name:e,uri:String(t?.uri||""),database:String(t?.database||"")}))}export function newConnectionRow(n){const e=new Set((n||[]).map(r=>r.name));let t=(n||[]).length+1;for(;e.has(`connection${t}`);)t++;return{id:`c${++s}`,name:t===1&&!e.has("default")?"default":`connection${t}`,uri:"",database:""}}export function connectionsFromRows(n){const e=[],t={};for(const r of n||[]){const i=String(r.name??"").trim();if(!i){e.push("Every connection needs a name.");continue}if(t[i]){e.push(`Two connections are called "${i}".`);continue}const o=String(r.uri??"").trim(),l=String(r.database??"").trim();o||e.push(`"${i}" needs a URI.`),l||e.push(`"${i}" needs a database.`),t[i]={type:"mongodb",uri:o,database:l,options:{}}}return{errors:e,config:t}}export function parseConnectionsJson(n){let e;try{e=JSON.parse(String(n??"").trim()||"{}")}catch(t){return{error:`Not valid JSON: ${t.message}`,config:null}}return!e||typeof e!="object"||Array.isArray(e)?{error:"The JSON must be an object of named connections.",config:null}:{error:"",config:e}}export function maskUri(n){return String(n??"").replace(/(\/\/[^:/@]+:)([^@]+)(@)/,"$1\u2022\u2022\u2022$3")}
1
+ export const SYSTEM_SLUGS=["roles","user-profiles"],FILTERS=["all","own","builtin","plugin"];const u="core";export function slugify(n){return String(n??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export function kindOf(n){return n?.builtIn===!0||n?.preset===!0||SYSTEM_SLUGS.includes(n?.slug)?"builtin":n?.plugin?"plugin":"own"}export const projectOf=n=>n?.meta?.project||u;export function arrangeCollections(n,{filter:t="all",query:e="",project:o=null}={}){const i=String(e).trim().toLowerCase();return[...n||[]].filter(r=>!o||projectOf(r)===o).filter(r=>t==="all"||kindOf(r)===t).filter(r=>!i||[r.title,r.slug,r.description,r.plugin].some(l=>String(l||"").toLowerCase().includes(i))).sort((r,l)=>String(r.title||r.slug).localeCompare(String(l.title||l.slug)))}export function kindCounts(n,{project:t=null}={}){const e=(n||[]).filter(i=>!t||projectOf(i)===t),o={all:e.length,own:0,builtin:0,plugin:0};for(const i of e)o[kindOf(i)]++;return o}const a=(n,t,e=`${t}s`)=>`${n} ${n===1?t:e}`;export function collectionRow(n){const t=kindOf(n),e=Array.isArray(n.fields)?n.fields.length:0,o=Number(n.entryCount)||0,i=n.storage?.adapter==="mongodb",r=n.meta?.project||"";return{slug:n.slug,title:n.title||n.slug,meta:[n.slug,a(e,"field"),r?`project ${r}`:""].filter(Boolean).join(" \xB7 "),entries:o,entriesText:o.toLocaleString("en-GB"),mongo:i,mongoTitle:i?`MongoDB - connection "${n.storage.connection||"default"}"`:"",builtin:t==="builtin",bundled:t!=="builtin"&&n.bundled===!0,plugin:n.plugin||"",pluginTitle:n.plugin?`Managed by the ${n.plugin} plugin`:"",canDelete:t!=="builtin",hasForm:!SYSTEM_SLUGS.includes(n.slug)}}export function checkNewCollection(n,t=[]){const e=String(n??"").trim(),o=slugify(e);return e?o?t.includes(o)?{slug:o,error:`"${o}" is already a collection.`}:{slug:o,error:""}:{slug:o,error:"The title needs at least one letter or number."}:{slug:"",error:""}}let s=0;export function connectionRows(n){return Object.entries(n||{}).filter(([t])=>t!=="_comment").map(([t,e])=>({id:`c${++s}`,name:t,uri:String(e?.uri||""),database:String(e?.database||""),options:e?.options&&typeof e.options=="object"?e.options:{}}))}export function newConnectionRow(n){const t=new Set((n||[]).map(o=>o.name));let e=(n||[]).length+1;for(;t.has(`connection${e}`);)e++;return{id:`c${++s}`,name:e===1&&!t.has("default")?"default":`connection${e}`,uri:"",database:""}}export function connectionsFromRows(n){const t=[],e={};for(const o of n||[]){const i=String(o.name??"").trim();if(!i){t.push("Every connection needs a name.");continue}if(e[i]){t.push(`Two connections are called "${i}".`);continue}const r=String(o.uri??"").trim(),l=String(o.database??"").trim();r||t.push(`"${i}" needs a URI.`),l||t.push(`"${i}" needs a database.`),e[i]={type:"mongodb",uri:r,database:l,options:o.options&&typeof o.options=="object"?o.options:{}}}return{errors:t,config:e}}export function parseConnectionsJson(n){let t;try{t=JSON.parse(String(n??"").trim()||"{}")}catch(e){return{error:`Not valid JSON: ${e.message}`,config:null}}return!t||typeof t!="object"||Array.isArray(t)?{error:"The JSON must be an object of named connections.",config:null}:{error:"",config:t}}export function maskUri(n){return String(n??"").replace(/(\/\/[^:/@]+:)([^@]+)(@)/,"$1\u2022\u2022\u2022$3")}
@@ -40,7 +40,7 @@
40
40
  <div class="clx-card">
41
41
  <div class="clx-bar">
42
42
  <strong>MongoDB connections</strong>
43
- <span data-help-title="Database connections" data-help="Collections are stored as files unless their storage is set to MongoDB, which needs a connection here - usually one called default. Each needs a URI (mongodb://…) and a database name. Kept in config/connections.json; with none, every collection stays on files. Ctrl+S saves."></span>
43
+ <span data-help-title="Database connections" data-help="Collections are stored as files unless their storage is set to MongoDB, which needs a connection here - usually one called default. Each needs a URI (mongodb://…) and a database name. Passwords are never shown: a saved one appears as dots - leave them to keep it, or type a new one. Change the host, user or options and you must type it again. Kept in config/connections.json; with none, every collection stays on files. Ctrl+S saves."></span>
44
44
  <span class="clx-spacer"></span>
45
45
  <label class="clx-toggle"><input type="checkbox" data-model="raw.value"> Raw JSON</label>
46
46
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="addConnection" data-bind-hidden="raw.value">
@@ -150,11 +150,14 @@
150
150
  <h3><span class="method-badge method-get">GET</span><span class="endpoint-path">/api/collections/connections</span></h3>
151
151
  <p class="auth-note">Requires Bearer token + an admin role (level 0 or 1).</p>
152
152
  <p>The MongoDB connections in <code>config/connections.json</code>. <code>PUT</code> saves them; each needs
153
- <code>type</code>, <code>uri</code> and <code>database</code>.</p>
153
+ <code>type</code>, <code>uri</code> and <code>database</code>. Passwords are never sent: the one in a URI, and any
154
+ secret-looking option, comes back as <code>••••••••</code>. Send the mask back unchanged to keep the stored value; it is restored only while scheme, user, hosts, query
155
+ and options are unchanged.</p>
154
156
  <pre class="code-block"><code>// Response 200
155
- { "default": { "type": "mongodb", "uri": "mongodb://localhost:27017", "database": "my_cms" } }
157
+ { "default": { "type": "mongodb", "uri": "mongodb://site:••••••••@localhost:27018/my_cms", "database": "my_cms" } }
156
158
  // PUT - Error 400
157
- { "error": "Connection \"default\" requires type, uri, and database" }</code></pre>
159
+ { "error": "Connection \"default\" requires type, uri, and database" }
160
+ { "error": "Connection \"default\": type the password again - it was hidden, and the connection now points somewhere else or it was never saved" }</code></pre>
158
161
 
159
162
  <h3 style="margin-top:24px;font-size:15px;text-transform:uppercase;letter-spacing:.5px;opacity:.6">
160
163
  Admin Entry CRUD</h3>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.101.6",
3
+ "version": "0.101.8",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -84,7 +84,7 @@
84
84
  "@fastify/rate-limit": "^10.3.0",
85
85
  "@fastify/static": "9.1.1",
86
86
  "bcryptjs": "^3.0.3",
87
- "domma-js": "0.50.2",
87
+ "domma-js": "0.50.3",
88
88
  "dotenv": "^17.2.3",
89
89
  "fastify": "5.8.5",
90
90
  "gray-matter": "^4.0.3",
@@ -63,6 +63,7 @@ import {ensureFormForCollection} from '../../services/forms.js';
63
63
  import {hooks} from '../../services/hooks.js';
64
64
  import {scopeAllows, validateToken} from '../../services/apiTokens.js';
65
65
  import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.js';
66
+ import {maskConnections, restoreConnections} from '../../services/connectionSecrets.js';
66
67
 
67
68
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
68
69
 
@@ -323,7 +324,7 @@ export async function collectionsRoutes(fastify) {
323
324
  fastify.get('/collections/connections', {preHandler: [authenticate, requireAdmin]}, async () => {
324
325
  try {
325
326
  const {_comment, ...rest} = getConfig('connections');
326
- return rest;
327
+ return maskConnections(rest);
327
328
  } catch {
328
329
  return {};
329
330
  }
@@ -339,7 +340,11 @@ export async function collectionsRoutes(fastify) {
339
340
  return reply.status(400).send({error: `Connection "${name}" requires type, uri, and database`});
340
341
  }
341
342
  }
342
- saveConfig('connections', data);
343
+ let stored = {};
344
+ try { stored = getConfig('connections'); } catch { /* nothing saved yet */ }
345
+ const {config, error} = restoreConnections(data, stored);
346
+ if (error) return reply.status(400).send({error});
347
+ saveConfig('connections', stored._comment !== undefined ? {_comment: stored._comment, ...config} : config);
343
348
  return {success: true};
344
349
  });
345
350
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Database connection secrets never reach the browser.
3
+ *
4
+ * GET /collections/connections sends each URI with its password, and any secret query
5
+ * parameter (tlsCertificateKeyFilePassword, proxyPassword, authMechanismProperties), replaced
6
+ * by CONNECTION_MASK, and any option whose key looks secret (auth.password) as the mask too.
7
+ * PUT sends the mask back for anything left untouched and restoreConnections() puts the stored
8
+ * value back - but only from a stored connection with the same endpoint (scheme, user, host
9
+ * list, query and driver options, secrets aside). Change where or how a connection
10
+ * authenticates and its secrets must be typed again, or a masked URI aimed at another host or
11
+ * proxy would carry the stored password there. The database path may change freely. A mask
12
+ * that cannot be matched is refused, never saved as the password. A URI whose credentials
13
+ * cannot be told apart from its query (a raw ? in the password) is masked whole.
14
+ */
15
+
16
+ export const CONNECTION_MASK = '••••••••';
17
+
18
+ const SECRET_KEY = /pass(word)?|secret|token|api[-_]?key|private|credential/i;
19
+ const SECRET_PARAM = /pass(word)?|secret|token|authMechanismProperties/i;
20
+
21
+ /**
22
+ * scheme://[user[:password]@]hosts[/path][?query]. The query is split off first and the LAST
23
+ * @ before it ends the credentials, so an @ in the query or an unescaped one in the password
24
+ * cannot move the split.
25
+ */
26
+ function parseUri(uri) {
27
+ if (typeof uri !== 'string') return null;
28
+ const m = uri.trim().match(/^([a-z][a-z0-9+.-]*:\/\/)(.*)$/is);
29
+ if (!m) return null;
30
+ const rest = m[2];
31
+ const qi = rest.indexOf('?');
32
+ const main = qi < 0 ? rest : rest.slice(0, qi);
33
+ const at = main.lastIndexOf('@');
34
+ // An @ only after the first ? is either in the query or behind a raw ? in the password:
35
+ // unknowable, so the whole URI is treated as one secret.
36
+ if (rest === CONNECTION_MASK || (at < 0 && qi >= 0 && rest.indexOf('@', qi) >= 0)) {
37
+ return {opaque: true, scheme: m[1].toLowerCase(), uri: uri.trim()};
38
+ }
39
+ const userinfo = at < 0 ? null : main.slice(0, at);
40
+ const hp = at < 0 ? main : main.slice(at + 1);
41
+ const si = hp.indexOf('/');
42
+ const ci = userinfo === null ? -1 : userinfo.indexOf(':');
43
+ return {
44
+ scheme: m[1].toLowerCase(),
45
+ user: userinfo === null ? null : (ci < 0 ? userinfo : userinfo.slice(0, ci)),
46
+ pass: ci < 0 ? null : userinfo.slice(ci + 1),
47
+ hosts: si < 0 ? hp : hp.slice(0, si),
48
+ path: si < 0 ? '' : hp.slice(si),
49
+ params: qi < 0 ? null : rest.slice(qi + 1).split('&').map(p => {
50
+ const e = p.indexOf('=');
51
+ return e < 0 ? [p, null] : [p.slice(0, e), p.slice(e + 1)];
52
+ })
53
+ };
54
+ }
55
+
56
+ function formatUri(p) {
57
+ if (p.opaque) return p.scheme + CONNECTION_MASK;
58
+ const cred = p.user === null ? '' : p.user + (p.pass === null ? '' : ':' + p.pass) + '@';
59
+ const query = p.params === null ? '' : '?' + p.params.map(([k, v]) => (v === null ? k : `${k}=${v}`)).join('&');
60
+ return p.scheme + cred + p.hosts + p.path + query;
61
+ }
62
+
63
+ const maskParams = (params) => params && params.map(([k, v]) => [k, v && SECRET_PARAM.test(k) ? CONNECTION_MASK : v]);
64
+
65
+ // Key order must not matter when comparing options.
66
+ const canonical = (v) => Array.isArray(v) ? v.map(canonical)
67
+ : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
68
+
69
+ /** Where and how a connection authenticates, secrets aside - whether stored secrets may follow it. */
70
+ function endpoint(p, options) {
71
+ const opts = canonical(maskOptions(options ?? {}));
72
+ if (p.opaque) return JSON.stringify(['opaque', p.scheme, opts]);
73
+ return JSON.stringify([p.scheme, p.user, p.hosts.toLowerCase(), maskParams(p.params), opts]);
74
+ }
75
+
76
+ /** A URI with its password and secret query parameters masked. */
77
+ export function maskConnectionUri(uri) {
78
+ const p = parseUri(uri);
79
+ if (!p) return uri;
80
+ if (p.opaque) return formatUri(p);
81
+ return formatUri({...p, pass: p.pass ? CONNECTION_MASK : p.pass, params: maskParams(p.params)});
82
+ }
83
+
84
+ function maskOptions(value, key = '') {
85
+ if (Array.isArray(value)) return value.map(v => maskOptions(v, key));
86
+ if (value && typeof value === 'object') {
87
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskOptions(v, k)]));
88
+ }
89
+ return SECRET_KEY.test(key) && typeof value === 'string' && value !== '' ? CONNECTION_MASK : value;
90
+ }
91
+
92
+ // A mask with nothing stored behind it stays a mask, so the caller refuses the save.
93
+ function restoreOptions(incoming, stored) {
94
+ if (incoming === CONNECTION_MASK) return typeof stored === 'string' ? stored : incoming;
95
+ if (Array.isArray(incoming)) {
96
+ const before = Array.isArray(stored) ? stored : [];
97
+ return incoming.map((v, i) => restoreOptions(v, before[i]));
98
+ }
99
+ if (incoming && typeof incoming === 'object') {
100
+ const out = {};
101
+ for (const [k, v] of Object.entries(incoming)) {
102
+ out[k] = restoreOptions(v, stored && typeof stored === 'object' ? stored[k] : undefined);
103
+ }
104
+ return out;
105
+ }
106
+ return incoming;
107
+ }
108
+
109
+ function restoreUri(incoming, stored) {
110
+ const pi = parseUri(incoming);
111
+ const ps = parseUri(stored);
112
+ if (pi.opaque) return ps.uri;
113
+ const pass = pi.pass === CONNECTION_MASK ? ps.pass : pi.pass;
114
+ const params = pi.params && pi.params.map(([k, v], i) => [k, v === CONNECTION_MASK ? ps.params[i][1] : v]);
115
+ return formatUri({...pi, pass, params});
116
+ }
117
+
118
+ /** The connections as the browser may see them. */
119
+ export function maskConnections(connections) {
120
+ const out = {};
121
+ for (const [name, conn] of Object.entries(connections || {})) {
122
+ if (!conn || typeof conn !== 'object') { out[name] = conn; continue; }
123
+ const masked = {...conn, uri: maskConnectionUri(conn.uri)};
124
+ if (conn.options !== undefined) masked.options = maskOptions(conn.options);
125
+ out[name] = masked;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * The connections to save: masks put back from what is stored.
132
+ * @returns {{config?: object, error?: string}}
133
+ */
134
+ export function restoreConnections(incoming, stored) {
135
+ const before = stored && typeof stored === 'object' ? stored : {};
136
+ const config = {};
137
+ for (const [name, conn] of Object.entries(incoming)) {
138
+ if (!conn || typeof conn !== 'object' || !JSON.stringify(conn).includes(CONNECTION_MASK)) {
139
+ config[name] = conn;
140
+ continue;
141
+ }
142
+ const pi = parseUri(conn.uri);
143
+ // The same name first, then any other stored connection at the same endpoint (a rename).
144
+ const candidates = [before[name], ...Object.entries(before).filter(([n]) => n !== name).map(([, c]) => c)];
145
+ const source = pi && candidates.find(c => {
146
+ const ps = c && typeof c === 'object' ? parseUri(c.uri) : null;
147
+ return ps && endpoint(ps, c.options) === endpoint(pi, conn.options);
148
+ });
149
+ const out = {...conn};
150
+ if (source) {
151
+ out.uri = restoreUri(conn.uri, source.uri);
152
+ if (conn.options !== undefined) out.options = restoreOptions(conn.options, source.options);
153
+ }
154
+ if (JSON.stringify(out).includes(CONNECTION_MASK)) {
155
+ return {error: `Connection "${name}": type the password again - it was hidden, and the connection now points somewhere else or it was never saved`};
156
+ }
157
+ config[name] = out;
158
+ }
159
+ return {config};
160
+ }