@things-factory/shell 10.1.29 → 10.1.33

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,7 +1,26 @@
1
1
  import { ValueTransformer } from 'typeorm';
2
2
  /**
3
- * ValueTransformer object for encrypting and decrypting database entity field values.
4
- * Used in TypeORM entities to encrypt field values before storing them in the database
5
- * and decrypt values when retrieving them from the database.
3
+ * Encrypting a database column, and reading one that may not be encrypted yet.
4
+ *
5
+ * ── Reading tells two failures apart ────────────────────────────────────────
6
+ * This used to answer `null` whenever decryption threw, which folded two very different
7
+ * things into one:
8
+ *
9
+ * the value was written before this column was encrypted → it is plaintext
10
+ * the value is ciphertext and the key cannot open it → something is wrong
11
+ *
12
+ * Both came back as "there is no value". A deployment with the wrong `dataEncryptionKey`
13
+ * therefore looked like a system holding no credentials at all — the rows were intact, one
14
+ * line went to the console, and nothing said which row or how many. Devices kept
15
+ * authenticating, because a signed token never consults these columns, so only the screens
16
+ * lost the values.
17
+ *
18
+ * So: a value that is not ciphertext-shaped is returned as it is, which is what lets a column
19
+ * be encrypted without a flag day. A value that IS ciphertext-shaped and will not open throws,
20
+ * because that is a broken key and not an absent value.
6
21
  */
7
22
  export declare const encryptTransformer: ValueTransformer;
23
+ /** Raised when a value is ciphertext and the configured key does not open it. */
24
+ export declare class DecryptionFailed extends Error {
25
+ constructor(cause: unknown);
26
+ }
@@ -1,36 +1,52 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.encryptTransformer = void 0;
3
+ exports.DecryptionFailed = exports.encryptTransformer = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const get_data_encryption_key_js_1 = require("./get-data-encryption-key.js");
6
6
  const ALGORITHM = 'aes-256-cbc';
7
- const KEY_LENGTH = 32;
8
- const SALT_LENGTH = 16;
9
7
  const IV_LENGTH = 16;
10
8
  /**
11
- * ValueTransformer object for encrypting and decrypting database entity field values.
12
- * Used in TypeORM entities to encrypt field values before storing them in the database
13
- * and decrypt values when retrieving them from the database.
9
+ * What this writes: `<iv as hex>:<ciphertext as hex>`. The IV is 16 bytes, so the prefix is
10
+ * exactly 32 hex characters, and the body is hex of even length.
11
+ *
12
+ * Reading matches on that shape rather than trying and seeing what happens, because the two
13
+ * failures below have to stay apart.
14
14
  */
15
- exports.encryptTransformer = {
16
- /**
17
- * Encrypts the entity field value before storing it in the database.
18
- * @param {string} entityValue - Unencrypted entity field value
19
- * @returns {string} - Encrypted string
20
- */
21
- to: (entityValue) => encrypt(entityValue), // DB에 저장하기 전에 암호화
22
- /**
23
- * Decrypts the encrypted database value when retrieving it from the database.
24
- * @param {string} databaseValue - Encrypted database field value
25
- * @returns {string} - Decrypted string
26
- */
27
- from: (databaseValue) => decrypt(databaseValue) // DB에서 값을 가져올 때 복호화
28
- };
15
+ const CIPHERTEXT = /^[0-9a-f]{32}:(?:[0-9a-f]{2})+$/;
29
16
  /**
30
- * Encrypts the given text using the AES-256-CBC algorithm.
31
- * @param {string} text - Text to encrypt
32
- * @returns {string} - Encrypted string
17
+ * Encrypting a database column, and reading one that may not be encrypted yet.
18
+ *
19
+ * ── Reading tells two failures apart ────────────────────────────────────────
20
+ * This used to answer `null` whenever decryption threw, which folded two very different
21
+ * things into one:
22
+ *
23
+ * the value was written before this column was encrypted → it is plaintext
24
+ * the value is ciphertext and the key cannot open it → something is wrong
25
+ *
26
+ * Both came back as "there is no value". A deployment with the wrong `dataEncryptionKey`
27
+ * therefore looked like a system holding no credentials at all — the rows were intact, one
28
+ * line went to the console, and nothing said which row or how many. Devices kept
29
+ * authenticating, because a signed token never consults these columns, so only the screens
30
+ * lost the values.
31
+ *
32
+ * So: a value that is not ciphertext-shaped is returned as it is, which is what lets a column
33
+ * be encrypted without a flag day. A value that IS ciphertext-shaped and will not open throws,
34
+ * because that is a broken key and not an absent value.
33
35
  */
36
+ exports.encryptTransformer = {
37
+ to: (entityValue) => encrypt(entityValue),
38
+ from: (databaseValue) => decrypt(databaseValue)
39
+ };
40
+ /** Raised when a value is ciphertext and the configured key does not open it. */
41
+ class DecryptionFailed extends Error {
42
+ constructor(cause) {
43
+ super('an encrypted column could not be decrypted with the configured dataEncryptionKey. ' +
44
+ 'The value is still in the database — this is a key problem, not a missing value.');
45
+ this.name = 'DecryptionFailed';
46
+ this.cause = cause;
47
+ }
48
+ }
49
+ exports.DecryptionFailed = DecryptionFailed;
34
50
  function encrypt(text) {
35
51
  if (!text) {
36
52
  return null;
@@ -40,25 +56,25 @@ function encrypt(text) {
40
56
  const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
41
57
  return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
42
58
  }
43
- /**
44
- * Decrypts the given encrypted text.
45
- * @param {string} text - Encrypted text to decrypt
46
- * @returns {string} - Decrypted string
47
- */
48
59
  function decrypt(text) {
49
60
  if (!text) {
50
61
  return null;
51
62
  }
63
+ /*
64
+ * Written before this column was encrypted. Handing it back unchanged is the whole of the
65
+ * backward compatibility: a column can start encrypting while the rows already in it stay
66
+ * readable, and each row converts the next time something writes it.
67
+ */
68
+ if (!CIPHERTEXT.test(text)) {
69
+ return text;
70
+ }
52
71
  try {
53
- const parts = text.split(':');
54
- const iv = Buffer.from(parts.shift(), 'hex');
55
- const encryptedText = Buffer.from(parts.join(':'), 'hex');
56
- const decipher = (0, crypto_1.createDecipheriv)(ALGORITHM, get_data_encryption_key_js_1.dataEncryptionKey, iv);
57
- return Buffer.concat([decipher.update(encryptedText), decipher.final()]).toString('utf8');
72
+ const [ivHex, bodyHex] = text.split(':');
73
+ const decipher = (0, crypto_1.createDecipheriv)(ALGORITHM, get_data_encryption_key_js_1.dataEncryptionKey, Buffer.from(ivHex, 'hex'));
74
+ return Buffer.concat([decipher.update(Buffer.from(bodyHex, 'hex')), decipher.final()]).toString('utf8');
58
75
  }
59
76
  catch (err) {
60
- console.error(`decryption for encrypted field failed in encryptTransformer`);
61
- return null;
77
+ throw new DecryptionFailed(err);
62
78
  }
63
79
  }
64
80
  //# sourceMappingURL=encrypt-transform.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"encrypt-transform.js","sourceRoot":"","sources":["../../server/typeorm/encrypt-transform.ts"],"names":[],"mappings":";;;AACA,mCAAkF;AAElF,6EAAgE;AAEhE,MAAM,SAAS,GAAG,aAAa,CAAA;AAC/B,MAAM,UAAU,GAAG,EAAE,CAAA;AACrB,MAAM,WAAW,GAAG,EAAE,CAAA;AACtB,MAAM,SAAS,GAAG,EAAE,CAAA;AAEpB;;;;GAIG;AACU,QAAA,kBAAkB,GAAqB;IAClD;;;;OAIG;IACH,EAAE,EAAE,CAAC,WAAmB,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,kBAAkB;IAErE;;;;OAIG;IACH,IAAI,EAAE,CAAC,aAAqB,EAAE,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,oBAAoB;CAC7E,CAAA;AAED;;;;GAIG;AACH,SAAS,OAAO,CAAC,IAAY;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,GAAG,IAAA,oBAAW,EAAC,SAAS,CAAC,CAAA;IACjC,MAAM,MAAM,GAAG,IAAA,uBAAc,EAAC,SAAS,EAAE,8CAAiB,EAAE,EAAE,CAAC,CAAA;IAC/D,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IAE9E,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AAC7D,CAAC;AAED;;;;GAIG;AACH,SAAS,OAAO,CAAC,IAAY;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC7B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,KAAK,CAAC,CAAA;QAC5C,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAA;QACzD,MAAM,QAAQ,GAAG,IAAA,yBAAgB,EAAC,SAAS,EAAE,8CAAiB,EAAE,EAAE,CAAC,CAAA;QAEnE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC3F,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,6DAA6D,CAAC,CAAA;QAC5E,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC","sourcesContent":["import { ValueTransformer } from 'typeorm'\nimport { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'\n\nimport { dataEncryptionKey } from './get-data-encryption-key.js'\n\nconst ALGORITHM = 'aes-256-cbc'\nconst KEY_LENGTH = 32\nconst SALT_LENGTH = 16\nconst IV_LENGTH = 16\n\n/**\n * ValueTransformer object for encrypting and decrypting database entity field values.\n * Used in TypeORM entities to encrypt field values before storing them in the database\n * and decrypt values when retrieving them from the database.\n */\nexport const encryptTransformer: ValueTransformer = {\n /**\n * Encrypts the entity field value before storing it in the database.\n * @param {string} entityValue - Unencrypted entity field value\n * @returns {string} - Encrypted string\n */\n to: (entityValue: string) => encrypt(entityValue), // DB에 저장하기 전에 암호화\n\n /**\n * Decrypts the encrypted database value when retrieving it from the database.\n * @param {string} databaseValue - Encrypted database field value\n * @returns {string} - Decrypted string\n */\n from: (databaseValue: string) => decrypt(databaseValue) // DB에서 값을 가져올 때 복호화\n}\n\n/**\n * Encrypts the given text using the AES-256-CBC algorithm.\n * @param {string} text - Text to encrypt\n * @returns {string} - Encrypted string\n */\nfunction encrypt(text: string): string {\n if (!text) {\n return null\n }\n\n const iv = randomBytes(IV_LENGTH)\n const cipher = createCipheriv(ALGORITHM, dataEncryptionKey, iv)\n const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])\n\n return `${iv.toString('hex')}:${encrypted.toString('hex')}`\n}\n\n/**\n * Decrypts the given encrypted text.\n * @param {string} text - Encrypted text to decrypt\n * @returns {string} - Decrypted string\n */\nfunction decrypt(text: string): string {\n if (!text) {\n return null\n }\n\n try {\n const parts = text.split(':')\n const iv = Buffer.from(parts.shift(), 'hex')\n const encryptedText = Buffer.from(parts.join(':'), 'hex')\n const decipher = createDecipheriv(ALGORITHM, dataEncryptionKey, iv)\n\n return Buffer.concat([decipher.update(encryptedText), decipher.final()]).toString('utf8')\n } catch (err) {\n console.error(`decryption for encrypted field failed in encryptTransformer`)\n return null\n }\n}\n"]}
1
+ {"version":3,"file":"encrypt-transform.js","sourceRoot":"","sources":["../../server/typeorm/encrypt-transform.ts"],"names":[],"mappings":";;;AACA,mCAAsE;AAEtE,6EAAgE;AAEhE,MAAM,SAAS,GAAG,aAAa,CAAA;AAC/B,MAAM,SAAS,GAAG,EAAE,CAAA;AAEpB;;;;;;GAMG;AACH,MAAM,UAAU,GAAG,iCAAiC,CAAA;AAEpD;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAqB;IAClD,EAAE,EAAE,CAAC,WAAmB,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC;IACjD,IAAI,EAAE,CAAC,aAAqB,EAAE,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC;CACxD,CAAA;AAED,iFAAiF;AACjF,MAAa,gBAAiB,SAAQ,KAAK;IACzC,YAAY,KAAc;QACxB,KAAK,CACH,oFAAoF;YAClF,kFAAkF,CACrF,CAAA;QAED,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAC7B;QAAC,IAAY,CAAC,KAAK,GAAG,KAAK,CAAA;IAC9B,CAAC;CACF;AAVD,4CAUC;AAED,SAAS,OAAO,CAAC,IAAY;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,GAAG,IAAA,oBAAW,EAAC,SAAS,CAAC,CAAA;IACjC,MAAM,MAAM,GAAG,IAAA,uBAAc,EAAC,SAAS,EAAE,8CAAiB,EAAE,EAAE,CAAC,CAAA;IAC/D,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IAE9E,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AAC7D,CAAC;AAED,SAAS,OAAO,CAAC,IAAY;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,IAAI,CAAA;IACb,CAAC;IAED;;;;OAIG;IACH,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,CAAC;QACH,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACxC,MAAM,QAAQ,GAAG,IAAA,yBAAgB,EAAC,SAAS,EAAE,8CAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;QAE1F,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IACzG,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,gBAAgB,CAAC,GAAG,CAAC,CAAA;IACjC,CAAC;AACH,CAAC","sourcesContent":["import { ValueTransformer } from 'typeorm'\nimport { createCipheriv, createDecipheriv, randomBytes } from 'crypto'\n\nimport { dataEncryptionKey } from './get-data-encryption-key.js'\n\nconst ALGORITHM = 'aes-256-cbc'\nconst IV_LENGTH = 16\n\n/**\n * What this writes: `<iv as hex>:<ciphertext as hex>`. The IV is 16 bytes, so the prefix is\n * exactly 32 hex characters, and the body is hex of even length.\n *\n * Reading matches on that shape rather than trying and seeing what happens, because the two\n * failures below have to stay apart.\n */\nconst CIPHERTEXT = /^[0-9a-f]{32}:(?:[0-9a-f]{2})+$/\n\n/**\n * Encrypting a database column, and reading one that may not be encrypted yet.\n *\n * ── Reading tells two failures apart ────────────────────────────────────────\n * This used to answer `null` whenever decryption threw, which folded two very different\n * things into one:\n *\n * the value was written before this column was encrypted → it is plaintext\n * the value is ciphertext and the key cannot open it → something is wrong\n *\n * Both came back as \"there is no value\". A deployment with the wrong `dataEncryptionKey`\n * therefore looked like a system holding no credentials at all — the rows were intact, one\n * line went to the console, and nothing said which row or how many. Devices kept\n * authenticating, because a signed token never consults these columns, so only the screens\n * lost the values.\n *\n * So: a value that is not ciphertext-shaped is returned as it is, which is what lets a column\n * be encrypted without a flag day. A value that IS ciphertext-shaped and will not open throws,\n * because that is a broken key and not an absent value.\n */\nexport const encryptTransformer: ValueTransformer = {\n to: (entityValue: string) => encrypt(entityValue),\n from: (databaseValue: string) => decrypt(databaseValue)\n}\n\n/** Raised when a value is ciphertext and the configured key does not open it. */\nexport class DecryptionFailed extends Error {\n constructor(cause: unknown) {\n super(\n 'an encrypted column could not be decrypted with the configured dataEncryptionKey. ' +\n 'The value is still in the database — this is a key problem, not a missing value.'\n )\n\n this.name = 'DecryptionFailed'\n ;(this as any).cause = cause\n }\n}\n\nfunction encrypt(text: string): string {\n if (!text) {\n return null\n }\n\n const iv = randomBytes(IV_LENGTH)\n const cipher = createCipheriv(ALGORITHM, dataEncryptionKey, iv)\n const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])\n\n return `${iv.toString('hex')}:${encrypted.toString('hex')}`\n}\n\nfunction decrypt(text: string): string {\n if (!text) {\n return null\n }\n\n /*\n * Written before this column was encrypted. Handing it back unchanged is the whole of the\n * backward compatibility: a column can start encrypting while the rows already in it stay\n * readable, and each row converts the next time something writes it.\n */\n if (!CIPHERTEXT.test(text)) {\n return text\n }\n\n try {\n const [ivHex, bodyHex] = text.split(':')\n const decipher = createDecipheriv(ALGORITHM, dataEncryptionKey, Buffer.from(ivHex, 'hex'))\n\n return Buffer.concat([decipher.update(Buffer.from(bodyHex, 'hex')), decipher.final()]).toString('utf8')\n } catch (err) {\n throw new DecryptionFailed(err)\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/shell",
3
- "version": "10.1.29",
3
+ "version": "10.1.33",
4
4
  "description": "Core module for framework",
5
5
  "bin": {
6
6
  "things-factory": "bin/things-factory",
@@ -126,5 +126,5 @@
126
126
  "pg": "^8.7.3",
127
127
  "sqlite3": "^5.0.8"
128
128
  },
129
- "gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
129
+ "gitHead": "7fb7b6587846b5ff6ee7a4983652d9561b4d6b2c"
130
130
  }
@@ -0,0 +1,130 @@
1
+ import { encryptTransformer, DecryptionFailed } from '../server/typeorm/encrypt-transform'
2
+
3
+ /**
4
+ * Reading an encrypted column has to tell two failures apart.
5
+ *
6
+ * The transformer used to answer `null` whenever decryption threw. That folded together a value
7
+ * written before the column was encrypted (plaintext, perfectly fine) and a value that is
8
+ * ciphertext the key will not open (something is wrong). Both read as "there is no value", so a
9
+ * deployment with the wrong `dataEncryptionKey` looked like a system holding no credentials —
10
+ * rows intact, one line on the console, no row named and no count.
11
+ *
12
+ * It also blocked turning encryption on at all: every row written before the change would have
13
+ * gone blank on the next read.
14
+ */
15
+
16
+ /** The shape `to` writes: 32 hex characters of IV, a colon, then hex of even length. */
17
+ const CIPHERTEXT = /^[0-9a-f]{32}:(?:[0-9a-f]{2})+$/
18
+
19
+ describe('writing an encrypted column', () => {
20
+ it('produces the iv:body shape', () => {
21
+ const stored = encryptTransformer.to('client-secret-value')
22
+
23
+ expect(stored).toMatch(CIPHERTEXT)
24
+ expect(stored).not.toContain('client-secret-value')
25
+ })
26
+
27
+ it('keeps nothing for an empty value', () => {
28
+ expect(encryptTransformer.to('')).toBeNull()
29
+ expect(encryptTransformer.to(null as any)).toBeNull()
30
+ })
31
+
32
+ it('writes a different string each time, so equal values do not look equal at rest', () => {
33
+ /* The IV is random per write. Without that, two rows holding the same secret would match. */
34
+ expect(encryptTransformer.to('same')).not.toEqual(encryptTransformer.to('same'))
35
+ })
36
+ })
37
+
38
+ describe('reading an encrypted column', () => {
39
+ it('round-trips what it wrote', () => {
40
+ expect(encryptTransformer.from(encryptTransformer.to('client-secret-value'))).toEqual('client-secret-value')
41
+ })
42
+
43
+ it('hands back a value written before the column was encrypted', () => {
44
+ /*
45
+ * ⚠ The case that makes turning encryption on possible at all. Every row already in the
46
+ * table is plaintext; if reading them answered null they would go blank on deploy.
47
+ */
48
+ expect(encryptTransformer.from('plain-client-secret')).toEqual('plain-client-secret')
49
+ })
50
+
51
+ it('hands back a JWT, which is what these columns actually hold', () => {
52
+ /*
53
+ * `Appliance.accessToken` and the shadow user's password hold signed JWTs. A JWT is dot
54
+ * separated and carries no colon, so it can never be mistaken for the ciphertext shape.
55
+ */
56
+ const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsInVzZXJUeXBlIjoiYXBwbGlhbmNlIn0.c2lnbmF0dXJl'
57
+
58
+ expect(encryptTransformer.from(token)).toEqual(token)
59
+ })
60
+
61
+ it('throws when the value is ciphertext and the key does not open it', () => {
62
+ /*
63
+ * ⚠ The case this file exists for. Answering null here is what made a wrong key look like an
64
+ * empty system. The row is still there; what is broken is the key.
65
+ */
66
+ const iv = 'a'.repeat(32)
67
+ const body = 'b'.repeat(64)
68
+
69
+ expect(() => encryptTransformer.from(`${iv}:${body}`)).toThrow(DecryptionFailed)
70
+ })
71
+
72
+ it('says in the message that the value still exists', () => {
73
+ /* Whoever reads the log has to know not to go re-issue credentials. */
74
+ const iv = 'a'.repeat(32)
75
+
76
+ expect(() => encryptTransformer.from(`${iv}:${'b'.repeat(64)}`)).toThrow(/still in the database/)
77
+ })
78
+
79
+ it('keeps nothing for an empty value', () => {
80
+ expect(encryptTransformer.from('')).toBeNull()
81
+ expect(encryptTransformer.from(null as any)).toBeNull()
82
+ })
83
+ })
84
+
85
+ describe('telling the two shapes apart', () => {
86
+ /*
87
+ * The split has to be exact in both directions: a plaintext value read as ciphertext throws on
88
+ * a perfectly good row, and a ciphertext value read as plaintext hands out the stored bytes.
89
+ */
90
+
91
+ it('does not take a colon alone for ciphertext', () => {
92
+ expect(encryptTransformer.from('host:5432')).toEqual('host:5432')
93
+ expect(encryptTransformer.from('Basic dXNlcjpwYXNz')).toEqual('Basic dXNlcjpwYXNz')
94
+ })
95
+
96
+ it('does not take hex of the wrong length for ciphertext', () => {
97
+ /* The IV is 16 bytes, so 32 hex characters and no other length. */
98
+ expect(encryptTransformer.from(`${'a'.repeat(31)}:${'b'.repeat(64)}`)).toEqual(
99
+ `${'a'.repeat(31)}:${'b'.repeat(64)}`
100
+ )
101
+ expect(encryptTransformer.from(`${'a'.repeat(33)}:${'b'.repeat(64)}`)).toEqual(
102
+ `${'a'.repeat(33)}:${'b'.repeat(64)}`
103
+ )
104
+ })
105
+
106
+ it('does not take an odd-length body for ciphertext', () => {
107
+ const odd = `${'a'.repeat(32)}:${'b'.repeat(63)}`
108
+
109
+ expect(encryptTransformer.from(odd)).toEqual(odd)
110
+ })
111
+
112
+ it('does not take uppercase hex for ciphertext', () => {
113
+ /* `to` writes lowercase. Something else wrote this, so it is not ours to decrypt. */
114
+ const upper = `${'A'.repeat(32)}:${'B'.repeat(64)}`
115
+
116
+ expect(encryptTransformer.from(upper)).toEqual(upper)
117
+ })
118
+
119
+ it('recognises its own output as ciphertext', () => {
120
+ /*
121
+ * The other direction of the same split, and the one that would silently hand out stored
122
+ * bytes if the pattern ever stopped matching what `to` writes.
123
+ */
124
+ const stored = encryptTransformer.to('secret')
125
+
126
+ expect(stored).toMatch(CIPHERTEXT)
127
+ expect(encryptTransformer.from(stored)).toEqual('secret')
128
+ expect(encryptTransformer.from(stored)).not.toEqual(stored)
129
+ })
130
+ })
@@ -12,11 +12,11 @@
12
12
  * compute time, so bundle order stops mattering, and a slot that is not opened cannot be reached.
13
13
  *
14
14
  * Three owners, one mechanism - no reliance on bundle order or specificity:
15
- * --brand-<slot> a product's accent (its own brand.css) 26 slots
15
+ * --brand-<slot> a product's accent (its own <product>-accent.css) 26 slots
16
16
  * --suite-<slot> the operato suite's shared greys (@things-factory/operato-suite) 17 slots
17
17
  * closed error x4, shadow, scrim - literal, nobody above the platform sets them 6 slots
18
18
  * A tenant's domain theme still wins over all of these: it writes --md-sys-color-* directly, later in
19
- * the bundle. Apps with neither brand.css nor the suite get the fallbacks, the shell values of before.
19
+ * the bundle. Apps with neither an accent file nor the suite get the fallbacks, the shell values of before.
20
20
  *
21
21
  * If md adds a slot the counts go red on purpose: someone has to decide whose it is.
22
22
  */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A refusal keeps its name on the way out, and gets its sentence from the caller's language.
3
+ *
4
+ * ── Why these four ────────────────────────────────────────────────────────
5
+ * The code is the contract (ADR-0042): a client reads `extensions.refusal.code` and decides what to
6
+ * show. If Apollo's own `code` were left empty it would read `INTERNAL_SERVER_ERROR`, and a refusal
7
+ * would look like a crash. If `t` were called without params the locale would be lost and a Korean
8
+ * reader would get English with no error anywhere. If a module has not declared the key, the English
9
+ * sentence has to win over the key printed raw.
10
+ *
11
+ * Moved here from operato-plant when worklist became the third consumer (ADR-0069 decision 3).
12
+ */
13
+
14
+ import { GraphQLError } from 'graphql'
15
+
16
+ import { Refusal, refusalKey, rethrowSpoken, speakRefusal } from '../server/refusal/refusal.js'
17
+
18
+ const refusal = () => new Refusal('approval-line-missing', { role: 'quality' }, 'no approval line stands for quality.')
19
+
20
+ describe('a refusal on the way out', () => {
21
+ it('carries its own name, and says the server refused on purpose', () => {
22
+ const error = speakRefusal(refusal(), {})
23
+
24
+ expect(error).toBeInstanceOf(GraphQLError)
25
+ expect(error.extensions.code).toBe('BAD_USER_INPUT')
26
+ expect(error.extensions.refusal).toEqual({ code: 'approval-line-missing', params: { role: 'quality' } })
27
+ })
28
+
29
+ it('asks for error.<code> and hands the values over, never undefined', () => {
30
+ const asked: Array<{ key: string; options?: Record<string, unknown> }> = []
31
+ const t = (key: string, options?: Record<string, unknown>) => {
32
+ asked.push({ key, options })
33
+ return '품질 결재선이 비어 있습니다.'
34
+ }
35
+
36
+ const error = speakRefusal(refusal(), { t })
37
+
38
+ expect(asked).toHaveLength(1)
39
+ expect(asked[0].key).toBe(refusalKey('approval-line-missing'))
40
+ /* `t(key, undefined)` answers in English however the request came in, so the values always travel. */
41
+ expect(asked[0].options).toEqual({ role: 'quality', defaultValue: 'no approval line stands for quality.' })
42
+ expect(error.message).toBe('품질 결재선이 비어 있습니다.')
43
+ })
44
+
45
+ it('reads as English when the module has not declared the key', () => {
46
+ /* i18next answers with defaultValue when the key is missing; a missing key must not print itself. */
47
+ const t = (_key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? _key)
48
+
49
+ expect(speakRefusal(refusal(), { t }).message).toBe('no approval line stands for quality.')
50
+ })
51
+ })
52
+
53
+ describe('rethrowSpoken', () => {
54
+ it('speaks a refusal', () => {
55
+ expect(() => rethrowSpoken(refusal(), {})).toThrow(GraphQLError)
56
+ })
57
+
58
+ it('leaves anything else exactly as it was - a crash is not a refusal', () => {
59
+ const crash = new TypeError('cannot read properties of undefined')
60
+
61
+ expect(() => rethrowSpoken(crash, {})).toThrow(crash)
62
+ })
63
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A theme file carries its owner's name - `themes/` is one namespace shared by every module.
3
+ *
4
+ * The builder's ThemeOverridePlugin collects `client/themes/*.css` from every module and keeps the
5
+ * FIRST file it meets for each file name, application first (webpack-plugins/theme-override-plugin.js).
6
+ * It never looks inside. So a module that ships `light.css` replaces the shell's `light.css` whole -
7
+ * including the 49 slots the shell opens as `var(--brand-*)` / `var(--suite-*)`, and including any
8
+ * fix made there later. A file with one declaration in it does that just as completely as a full
9
+ * palette.
10
+ *
11
+ * Hence the rule (ADR-0060 decision 2-1): inside things-factory only the shell carries the names of
12
+ * the shell's theme files. A product that means to replace one of them wholesale may still do it -
13
+ * that is what an application's own light.css was - but then it owns all 49 slots and stops
14
+ * receiving anything the platform decides.
15
+ *
16
+ * The exceptions below are named, with what has to happen for each to go. When one is dealt with
17
+ * this test goes red and the line gets deleted - that is the point of listing them rather than
18
+ * filtering them out.
19
+ */
20
+
21
+ import { readdirSync, existsSync } from 'fs'
22
+ import path from 'path'
23
+
24
+ const PACKAGES = path.resolve(__dirname, '..', '..')
25
+ const SHELL_THEMES = path.resolve(__dirname, '..', 'client', 'themes')
26
+
27
+ /**
28
+ * Files that carry a shell theme file's name today.
29
+ *
30
+ * - The generator templates are copied into a new application, so a new product is born replacing
31
+ * the shell theme - operato-warehouse was born that way. They are fixed once the four operato
32
+ * products have moved.
33
+ */
34
+ const KNOWN: string[] = [
35
+ 'generator/templates/app/client/themes/dark.css',
36
+ 'generator/templates/app/client/themes/light.css',
37
+ 'generator/templates/brand/client/themes/dark.css',
38
+ 'generator/templates/brand/client/themes/light.css'
39
+ ]
40
+
41
+ /** Every `client/themes/*.css` under packages, as a path relative to packages. */
42
+ function themeFiles(): string[] {
43
+ const found: string[] = []
44
+
45
+ const walk = (dir: string) => {
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name.startsWith('dist')) {
48
+ continue
49
+ }
50
+
51
+ const full = path.join(dir, entry.name)
52
+
53
+ if (entry.isDirectory()) {
54
+ walk(full)
55
+ } else if (entry.name.endsWith('.css') && path.basename(path.dirname(full)) === 'themes') {
56
+ if (path.basename(path.dirname(path.dirname(full))) === 'client') {
57
+ found.push(path.relative(PACKAGES, full))
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ walk(PACKAGES)
64
+
65
+ return found.sort()
66
+ }
67
+
68
+ describe('theme file names', () => {
69
+ it('finds the theme files at all - an empty scan would pass everything below', () => {
70
+ const files = themeFiles()
71
+
72
+ expect(files.length).toBeGreaterThan(20)
73
+ expect(files).toContain(path.join('shell', 'client', 'themes', 'light.css'))
74
+ })
75
+
76
+ it('only the shell carries the names of the shell theme files', () => {
77
+ const shellNames = new Set(readdirSync(SHELL_THEMES))
78
+ const collisions = themeFiles().filter(
79
+ file => !file.startsWith(`shell${path.sep}`) && shellNames.has(path.basename(file))
80
+ )
81
+
82
+ expect(collisions).toEqual(KNOWN.map(file => file.split('/').join(path.sep)))
83
+ })
84
+
85
+ it('every exception still exists - a stale line hides the next collision', () => {
86
+ for (const file of KNOWN) {
87
+ expect(existsSync(path.join(PACKAGES, file.split('/').join(path.sep)))).toBe(true)
88
+ }
89
+ })
90
+ })