From e56f725bd973fd789cb2ad76a8fadc1cdc960ae0 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 15 Sep 2026 08:48:24 +0200 Subject: [PATCH] feat(companysetting): add writeonly column and mask values for GUI --- API.companysetting.md | 21 +++--- README.md | 12 ++-- schema-graph.dot | 4 +- schema-graph.svg | 58 ++++++++------- src/schema.js | 91 ++++++++++++------------ src/sql/companysetting.js | 15 ++-- src/store/companysetting/get.js | 16 +++-- src/store/companysetting/list.js | 17 +++-- src/store/companysetting/set.js | 22 +++++- src/store/companysetting/shapeSetting.js | 6 +- 10 files changed, 155 insertions(+), 107 deletions(-) diff --git a/API.companysetting.md b/API.companysetting.md index a9134eb..764c230 100644 --- a/API.companysetting.md +++ b/API.companysetting.md @@ -16,23 +16,24 @@ Returns a single company setting by key for the user's selected company. | `key` | `string` | The setting key. | | `[options]` | `object` | Optional options. | | `[options.frontendOnly]` | `boolean` | Only return the setting if it is frontend-visible. | +| `[options.maskWriteonly]` | `boolean` | Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value). | **Returns** -`Promise<{key: string, value: string, frontend: boolean}|undefined>` — The setting, or `undefined` if not found. +`Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>` — The setting, or `undefined` if not found. **Example** ```js -const setting = await store.companysetting.get(42, 'theme'); -// => { key, value, frontend } +const setting = await store.companysetting.get(42, 'ui.theme'); +// => { key, value, frontend, writeonly } ``` ## `companysetting.list` *Source: `src/store/companysetting/list.js`* -Lists the settings of the user's selected company, optionally only frontend-visible ones. +Lists the settings of the user's selected company, optionally only frontend-visible ones. Write-only settings (such as `keys.tavily`, `keys.openrouter`) have their value masked with `'***'` by default for usergui presentation. **Parameters** @@ -41,23 +42,24 @@ Lists the settings of the user's selected company, optionally only frontend-visi | `googleAuthId` | `number` | The id of the Google auth user. | | `[options]` | `object` | Optional options. | | `[options.frontendOnly]` | `boolean` | Only return frontend-visible settings. | +| `[options.maskWriteonly]` | `boolean` | Whether to mask writeonly settings with '***'. Defaults to `true`. | **Returns** -`Promise>` — The settings. +`Promise>` — The settings. **Example** ```js const settings = await store.companysetting.list(42, { frontendOnly: true }); -// => [{ key, value, frontend }] +// => [{ key, value, frontend, writeonly }] ``` ## `companysetting.set` *Source: `src/store/companysetting/set.js`* -Sets (upserts) a company setting by key for the user's selected company. Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. +Sets (upserts) a company setting by key for the user's selected company. Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored with `writeonly = 1`; when retrieved by the GUI the value is returned as `"***"`. The backend always receives the real value. **Parameters** @@ -68,10 +70,11 @@ Sets (upserts) a company setting by key for the user's selected company. Only a | `input.key` | `string` | The setting key (must be in the allow-list). | | `input.value` | `string` | The setting value. | | `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | +| `[input.writeonly]` | `boolean` | Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. | **Returns** -`Promise<{key: string, value: string, frontend: boolean}>` — The upserted setting. +`Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>` — The upserted setting. **Throws** @@ -81,5 +84,5 @@ Sets (upserts) a company setting by key for the user's selected company. Only a ```js const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); -// => { key, value, frontend } +// => { key, value, frontend, writeonly } ``` diff --git a/README.md b/README.md index f715b68..56a77ac 100644 --- a/README.md +++ b/README.md @@ -160,13 +160,15 @@ Company-scoped key/value settings. Auth via selected company (`google_auth.selec | Method | Signature | Returns | | --- | --- | --- | -| `list` | `(googleAuthId, { frontendOnly? })` | `{ key, value, frontend }[]` | -| `get` | `(googleAuthId, key, { frontendOnly? })` | `{ key, value, frontend } \| undefined` | -| `set` | `(googleAuthId, { key, value, frontend? })` | `{ key, value, frontend }` | +| `list` | `(googleAuthId, { frontendOnly?, maskWriteonly? })` | `{ key, value, frontend, writeonly }[]` | +| `get` | `(googleAuthId, key, { frontendOnly?, maskWriteonly? })` | `{ key, value, frontend, writeonly } \| undefined` | +| `set` | `(googleAuthId, { key, value, frontend?, writeonly? })` | `{ key, value, frontend, writeonly }` | - `value` is always a string (`NVARCHAR(MAX)`). - `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only). - With `frontendOnly: true`, backend-only keys are omitted / `get` returns `undefined` (no leak via error type beyond missing). +- `writeonly: true` → usergui gets a `"***"` scrambled version when retrieving the key (in `list`, or in `get` when `maskWriteonly` is enabled / `frontendOnly: true`). The backend receives the full value. +- `keys.tavily` and `keys.openrouter` default to `writeonly: true` on `set`. - `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. - No delete endpoint — settings are upsert / read only. @@ -295,8 +297,8 @@ Maps articles to suppliers (`entity`) with purchase pricing, stock, packaging un | `article_id` | `INT` | FK → `article(id)` (`ON DELETE CASCADE`) | | `entity_id` | `INT` | FK → `entity(id)` (`ON DELETE CASCADE`) | | `price_id` | `INT` | FK → `price(id)` (`ON DELETE NO ACTION`), nullable | -| `SupplierSKU` | `NVARCHAR(100)` | Supplier's SKU code, nullable | -| `SupplierStock` | `FLOAT` | Stock available at supplier, nullable | +| `supplierSKU` | `NVARCHAR(100)` | Supplier's SKU code, nullable | +| `supplierStock` | `FLOAT` | Stock available at supplier, nullable | | `pu` | `FLOAT` | Packaging unit (Verpackungseinheit), default `1` | --- diff --git a/schema-graph.dot b/schema-graph.dot index f7d955b..a891f1a 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -19,7 +19,7 @@ digraph schema { entitycontact [label=<
entitycontact
idINT PK
entity_idINT FK
contacttypeNVARCHAR(50)
contactNVARCHAR(255)
sortINT
>]; syncpos [label=<
syncpos
idINT PK
company_idINT FK
paircodeNVARCHAR(50)
token?NVARCHAR(255)
>]; entityhistory [label=<
entityhistory
idINT PK
entity_idINT FK
companyuser_id?INT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; - companysetting [label=<
companysetting
idINT PK
company_idINT FK
keyNVARCHAR(255)
valueNVARCHAR(MAX)
frontendBIT
>]; + companysetting [label=<
companysetting
idINT PK
company_idINT FK
keyNVARCHAR(255)
valueNVARCHAR(MAX)
frontendBIT
writeonlyBIT
>]; google_auth [label=<
google_auth
idINT PK
selected_company?INT FK
emailNVARCHAR(320)
picture?NVARCHAR(2048)
picturebinary?VARBINARY(MAX)
sub?NVARCHAR(255)
created_atDATETIME2
>]; } subgraph cluster_i18n { @@ -43,7 +43,7 @@ digraph schema { articleean [label=<
articleean
idINT PK
article_idINT FK
eanNVARCHAR(50)
>]; articlelist [label=<
articlelist
idINT PK
>]; articlelistarticle [label=<
articlelistarticle
idINT PK
article_idINT FK
articlelist_idINT FK
countINT
sortINT
>]; - articlesupplier [label=<
articlesupplier
idINT PK
price_id?INT FK
article_idINT FK
entity_idINT FK
SupplierSKU?NVARCHAR(100)
SupplierStock?FLOAT
puFLOAT
>]; + articlesupplier [label=<
articlesupplier
idINT PK
price_id?INT FK
article_idINT FK
entity_idINT FK
SupplierSKU?NVARCHAR(100)
supplierStock?FLOAT
puFLOAT
>]; optionslist [label=<
optionslist
idINT PK
>]; optionslistarticle [label=<
optionslistarticle
idINT PK
article_idINT FK
optionslist_idINT FK
countINT
sortINT
mandatedBIT
>]; } diff --git a/schema-graph.svg b/schema-graph.svg index 2673be6..c2d43e1 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -641,38 +641,42 @@ companysetting - - -companysetting - - -id -INT -PK - - -company_id -INT -FK - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) - - -frontend -BIT + + +companysetting + + +id +INT +PK + + +company_id +INT +FK + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) + + +frontend +BIT + + +writeonly +BIT companysetting:w->company:n - - + + diff --git a/src/schema.js b/src/schema.js index a891217..ce1bab2 100644 --- a/src/schema.js +++ b/src/schema.js @@ -536,8 +536,8 @@ BEGIN article_id INT NOT NULL, entity_id INT NOT NULL, price_id INT NULL, - SupplierSKU NVARCHAR(100) NULL, - SupplierStock FLOAT NULL, + supplierSKU NVARCHAR(100) NULL, + supplierStock FLOAT NULL, pu FLOAT NOT NULL CONSTRAINT DF_articlesupplier_pu DEFAULT 1, CONSTRAINT FK_articlesupplier_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE, @@ -762,6 +762,7 @@ BEGIN [key] NVARCHAR(255) NOT NULL, value NVARCHAR(MAX) NOT NULL, frontend BIT NOT NULL CONSTRAINT DF_companysetting_frontend DEFAULT 0, + writeonly BIT NOT NULL CONSTRAINT DF_companysetting_writeonly DEFAULT 0, CONSTRAINT UQ_companysetting_company_key UNIQUE (company_id, [key]), CONSTRAINT FK_companysetting_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE ); @@ -799,47 +800,47 @@ END /** DDL scripts in dependency order for schema init. */ export const SCHEMA_SCRIPTS = [ - INIT_META, - INIT_LANGUAGE, - INIT_TAX, - INIT_CATEGORY, - INIT_PIC, - INIT_COMPANY, - INIT_GOOGLE_AUTH, - INIT_CUSTOMERGROUP, - INIT_COMPANY_POS_CUSTOMERGROUP_FK, - INIT_ENTITY, - INIT_ENTITY_COMPANY_NUMBER_UQ, - INIT_ENTITYADDRESS, - INIT_ENTITYCONTACT, - INIT_UNIT, - INIT_UNITTEXT, - INIT_OPTIONSLIST, - INIT_ARTICLELIST, - INIT_BRAND, - INIT_ARTICLE, - INIT_ARTICLEEAN, - INIT_ARTICLEPIC, - INIT_BRANDPIC, - INIT_ARTICLELISTARTICLE, - INIT_OPTIONSLISTARTICLE, - INIT_CATEGORYARTICLE, - INIT_CATEGORYPIC, - INIT_CATEGORYTEXT, - INIT_ARTICLENAME, - INIT_PRICE, - INIT_ARTICLE_SUPPLIER, - INIT_MANIFEST, - INIT_MANIFESTARTICLE, - INIT_LEDGER, - INIT_NUMBERS, - INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, - INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, - INIT_COMPANYUSER, - INIT_ARTICLEHISTORY, - INIT_ENTITYHISTORY, - INIT_SYNCPOS, - INIT_COMPANYSETTING, - INIT_POSTERMINAL, - INIT_POSTERMINALCATEGORY, + INIT_META, + INIT_LANGUAGE, + INIT_TAX, + INIT_CATEGORY, + INIT_PIC, + INIT_COMPANY, + INIT_GOOGLE_AUTH, + INIT_CUSTOMERGROUP, + INIT_COMPANY_POS_CUSTOMERGROUP_FK, + INIT_ENTITY, + INIT_ENTITY_COMPANY_NUMBER_UQ, + INIT_ENTITYADDRESS, + INIT_ENTITYCONTACT, + INIT_UNIT, + INIT_UNITTEXT, + INIT_OPTIONSLIST, + INIT_ARTICLELIST, + INIT_BRAND, + INIT_ARTICLE, + INIT_ARTICLEEAN, + INIT_ARTICLEPIC, + INIT_BRANDPIC, + INIT_ARTICLELISTARTICLE, + INIT_OPTIONSLISTARTICLE, + INIT_CATEGORYARTICLE, + INIT_CATEGORYPIC, + INIT_CATEGORYTEXT, + INIT_ARTICLENAME, + INIT_PRICE, + INIT_ARTICLE_SUPPLIER, + INIT_MANIFEST, + INIT_MANIFESTARTICLE, + INIT_LEDGER, + INIT_NUMBERS, + INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, + INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, + INIT_COMPANYUSER, + INIT_ARTICLEHISTORY, + INIT_ENTITYHISTORY, + INIT_SYNCPOS, + INIT_COMPANYSETTING, + INIT_POSTERMINAL, + INIT_POSTERMINALCATEGORY, ]; diff --git a/src/sql/companysetting.js b/src/sql/companysetting.js index c02445d..066a123 100644 --- a/src/sql/companysetting.js +++ b/src/sql/companysetting.js @@ -2,7 +2,8 @@ export const LIST_BY_COMPANY = ` SELECT [key], value, - CAST(frontend AS INT) AS frontend + CAST(frontend AS INT) AS frontend, + CAST(writeonly AS INT) AS writeonly FROM companysetting WHERE company_id = @companyId ORDER BY [key] @@ -12,7 +13,8 @@ export const LIST_BY_COMPANY_FRONTEND = ` SELECT [key], value, - CAST(frontend AS INT) AS frontend + CAST(frontend AS INT) AS frontend, + CAST(writeonly AS INT) AS writeonly FROM companysetting WHERE company_id = @companyId AND frontend = 1 @@ -23,7 +25,8 @@ export const GET_BY_COMPANY_AND_KEY = ` SELECT [key], value, - CAST(frontend AS INT) AS frontend + CAST(frontend AS INT) AS frontend, + CAST(writeonly AS INT) AS writeonly FROM companysetting WHERE company_id = @companyId AND [key] = @key @@ -34,8 +37,8 @@ MERGE companysetting AS target USING (SELECT @companyId AS company_id, @key AS [key]) AS src ON target.company_id = src.company_id AND target.[key] = src.[key] WHEN MATCHED THEN - UPDATE SET value = @value, frontend = @frontend + UPDATE SET value = @value, frontend = @frontend, writeonly = @writeonly WHEN NOT MATCHED THEN - INSERT (company_id, [key], value, frontend) - VALUES (@companyId, @key, @value, @frontend); + INSERT (company_id, [key], value, frontend, writeonly) + VALUES (@companyId, @key, @value, @frontend, @writeonly); `; diff --git a/src/store/companysetting/get.js b/src/store/companysetting/get.js index 470db46..9002577 100644 --- a/src/store/companysetting/get.js +++ b/src/store/companysetting/get.js @@ -12,13 +12,18 @@ export default function get(pool) { * @param {string} key - The setting key. * @param {object} [options] - Optional options. * @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible. - * @returns {Promise<{key: string, value: string, frontend: boolean}|undefined>} The setting, or `undefined` if not found. + * @param {boolean} [options.maskWriteonly] - Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value). + * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>} The setting, or `undefined` if not found. * * @example - * const setting = await store.companysetting.get(42, 'theme'); - * // => { key, value, frontend } + * const setting = await store.companysetting.get(42, 'ui.theme'); + * // => { key, value, frontend, writeonly } */ - return async (googleAuthId, key, { frontendOnly = false } = {}) => { + return async ( + googleAuthId, + key, + { frontendOnly = false, maskWriteonly } = {}, + ) => { if (!key?.trim()) { return undefined; } @@ -39,6 +44,7 @@ export default function get(pool) { return undefined; } - return shapeSetting(row); + const shouldMask = maskWriteonly ?? frontendOnly; + return shapeSetting(row, { exposeValue: !shouldMask }); }; } diff --git a/src/store/companysetting/list.js b/src/store/companysetting/list.js index ec97126..7879f2b 100644 --- a/src/store/companysetting/list.js +++ b/src/store/companysetting/list.js @@ -11,16 +11,23 @@ export default function list(pool) { /** * Lists the settings of the user's selected company, optionally only frontend-visible ones. * + * Write-only settings (such as `keys.tavily`, `keys.openrouter`) have their value masked + * with `'***'` by default for usergui presentation. + * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} [options] - Optional options. * @param {boolean} [options.frontendOnly] - Only return frontend-visible settings. - * @returns {Promise>} The settings. + * @param {boolean} [options.maskWriteonly] - Whether to mask writeonly settings with '***'. Defaults to `true`. + * @returns {Promise>} The settings. * * @example * const settings = await store.companysetting.list(42, { frontendOnly: true }); - * // => [{ key, value, frontend }] + * // => [{ key, value, frontend, writeonly }] */ - return async (googleAuthId, { frontendOnly = false } = {}) => { + return async ( + googleAuthId, + { frontendOnly = false, maskWriteonly = true } = {}, + ) => { const { companyId } = await access(googleAuthId); const result = await pool @@ -28,6 +35,8 @@ export default function list(pool) { .input('companyId', companyId) .query(frontendOnly ? LIST_BY_COMPANY_FRONTEND : LIST_BY_COMPANY); - return result.recordset.map(shapeSetting); + return result.recordset.map((row) => + shapeSetting(row, { exposeValue: !maskWriteonly }), + ); }; } diff --git a/src/store/companysetting/set.js b/src/store/companysetting/set.js index 85358b4..96e804a 100644 --- a/src/store/companysetting/set.js +++ b/src/store/companysetting/set.js @@ -10,6 +10,13 @@ const ALLOWED_KEYS = new Set([ 'labelprinter.type', ]); +// Keys that are write-only by default: the GUI receives a scrambled "***" +// placeholder when reading, but the backend always gets the real value. +const WRITEONLY_KEYS = new Set([ + 'keys.tavily', + 'keys.openrouter', +]); + export default function set(pool) { const access = resolveSelectedCompanyAccess(pool); @@ -19,17 +26,22 @@ export default function set(pool) { * Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`, * `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. * + * Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored + * with `writeonly = 1`; when retrieved by the GUI the value is returned as + * `"***"`. The backend always receives the real value. + * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - The setting input. * @param {string} input.key - The setting key (must be in the allow-list). * @param {string} input.value - The setting value. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible. - * @returns {Promise<{key: string, value: string, frontend: boolean}>} The upserted setting. + * @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. + * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting. * @throws {Error} If the input is invalid. * * @example * const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); - * // => { key, value, frontend } + * // => { key, value, frontend, writeonly } */ return async (googleAuthId, input = {}) => { const key = input.key?.trim(); @@ -41,6 +53,10 @@ export default function set(pool) { } const frontend = Boolean(input.frontend); + const writeonly = + input.writeonly !== undefined + ? Boolean(input.writeonly) + : WRITEONLY_KEYS.has(key); const { companyId } = await access(googleAuthId); await pool @@ -49,6 +65,7 @@ export default function set(pool) { .input('key', key) .input('value', input.value) .input('frontend', frontend) + .input('writeonly', writeonly) .query(UPSERT); const result = await pool @@ -57,6 +74,7 @@ export default function set(pool) { .input('key', key) .query(GET_BY_COMPANY_AND_KEY); + // Return the masked value to the GUI; the DB holds the real value. return shapeSetting(result.recordset[0]); }; } diff --git a/src/store/companysetting/shapeSetting.js b/src/store/companysetting/shapeSetting.js index 0e77fd0..f1edf67 100644 --- a/src/store/companysetting/shapeSetting.js +++ b/src/store/companysetting/shapeSetting.js @@ -1,8 +1,10 @@ -function shapeSetting(row) { +function shapeSetting(row, { exposeValue = false } = {}) { + const writeonly = Boolean(row.writeonly); return { key: row.key, - value: row.value, + value: writeonly && !exposeValue ? '***' : row.value, frontend: Boolean(row.frontend), + writeonly, }; }