@dhyasama/totem-merge 7.1.0 → 7.1.1

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.
package/bin/bootstrap ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ var runtimeSecrets = require('../lib/runtimeSecrets');
5
+
6
+ runtimeSecrets.loadRuntimeSecrets()
7
+ .then(function() { require('../cli'); })
8
+ .catch(function(error) {
9
+ console.error('runtime_secret_bootstrap_failed', { code: error.name || error.code || 'UnknownError' });
10
+ process.exit(1);
11
+ });
package/config.js CHANGED
@@ -8,12 +8,8 @@ module.exports = {
8
8
 
9
9
  suppressDebugLog: true,
10
10
 
11
- // db: {
12
- // uri: 'mongodb://ffvc:ytnn5m7A9snIJSOLRqwo7Bpo2QkyYx6q@ds043952.mongolab.com:43952/ff-platform-test'
13
- // },
14
-
15
11
  db: {
16
- uri: 'mongodb://jason:T576G150HPXLA5q4BJ2o2zj747B5030x@development-shard-00-00-gv0f3.mongodb.net:27017,development-shard-00-01-gv0f3.mongodb.net:27017,development-shard-00-02-gv0f3.mongodb.net:27017/development?ssl=true&replicaSet=Development-shard-0&authSource=admin'
12
+ uri: process.env.DB_URI || ''
17
13
  },
18
14
 
19
15
  crypto: {
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+ const { test } = require('node:test');
7
+ const root = path.resolve(__dirname, '../..');
8
+ const files = fs.existsSync(path.join(root, 'lib/runtimeSecrets.js'))
9
+ ? ['lib/runtimeSecrets.js']
10
+ : fs.readdirSync(root).flatMap(dir => ['runtimeSecrets.js', 'runtime_secrets.js']
11
+ .map(name => path.join(dir, 'src/libs', name)).filter(file => fs.existsSync(path.join(root, file))));
12
+ assert(files.length > 0, 'No runtime loaders found');
13
+
14
+ function fixture(file, env = {}) {
15
+ const process = { env: { NODE_ENV: 'test', ...env } };
16
+ const module = { exports: {} };
17
+ const warnings = [];
18
+ class Client { constructor(options) { this.options = options; } }
19
+ class Command { constructor(input) { this.input = input; } }
20
+ const sdk = { SecretsManager: Client, SecretsManagerClient: Client, GetSecretValueCommand: Command };
21
+ vm.runInNewContext(fs.readFileSync(path.join(root, file), 'utf8'), {
22
+ module, exports: module.exports, process, Buffer,
23
+ console: { warn: (...args) => warnings.push(args), info: () => {} },
24
+ require: name => {
25
+ assert(['aws-sdk', '@aws-sdk/client-secrets-manager'].includes(name));
26
+ return sdk;
27
+ }
28
+ }, { filename: file });
29
+ const loader = module.exports;
30
+ const values = Object.fromEntries((loader.REQUIRED_KEYS || ['DB_URI']).map(key => [key, 'synthetic-' + key]));
31
+ const response = () => ({ SecretString: JSON.stringify(values) });
32
+ const client = fn => ({ send: fn, getSecretValue: () => ({ promise: fn }) });
33
+ return { loader, env: process.env, values, response, client, warnings };
34
+ }
35
+
36
+ for (const file of files) {
37
+ test(file + ': explicit secret ID required in deployed runtimes', async () => {
38
+ for (const env of [
39
+ { NODE_ENV: 'production', RUNTIME_SECRET_REQUIRED: 'false' },
40
+ { AWS_LAMBDA_FUNCTION_NAME: 'synthetic-function', RUNTIME_SECRET_REQUIRED: 'false' },
41
+ { RUNTIME_SECRET_REQUIRED: 'true' },
42
+ { NODE_ENV: 'production', RUNTIME_SECRET_ID: ' ' }
43
+ ]) {
44
+ const f = fixture(file, env);
45
+ let called = false;
46
+ await assert.rejects(f.loader.loadRuntimeSecrets({ client: f.client(async () => { called = true; }) }), /RUNTIME_SECRET_ID is required/);
47
+ assert.equal(called, false);
48
+ }
49
+ });
50
+ test(file + ': failed production fetch can recover without restarting', async () => {
51
+ const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret', RUNTIME_SECRET_REQUIRED: 'false' });
52
+ let calls = 0;
53
+ const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic transient failure'); return f.response(); });
54
+ await assert.rejects(f.loader.loadRuntimeSecrets({ client }), /synthetic transient failure/);
55
+ assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
56
+ assert.equal(calls, 2);
57
+ assert.equal(f.warnings.length, 0);
58
+ });
59
+ test(file + ': concurrent calls share one fetch and successful values stay cached', async () => {
60
+ const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
61
+ let calls = 0, resolve;
62
+ const client = f.client(() => { calls++; return new Promise(done => { resolve = done; }); });
63
+ const first = f.loader.loadRuntimeSecrets({ client });
64
+ const second = f.loader.loadRuntimeSecrets({ client });
65
+ assert.equal(first, second);
66
+ resolve(f.response());
67
+ await Promise.all([first, second]);
68
+ await f.loader.loadRuntimeSecrets({ client });
69
+ assert.equal(calls, 1);
70
+ for (const key of Object.keys(f.values)) assert.equal(f.env[key], f.values[key]);
71
+ });
72
+ test(file + ': invalid secret never partially overwrites configuration and can retry', async () => {
73
+ const f = fixture(file, { NODE_ENV: 'production', RUNTIME_SECRET_ID: '/synthetic/secret' });
74
+ const keys = Object.keys(f.values);
75
+ for (const key of keys) f.env[key] = 'old-synthetic-value';
76
+ let calls = 0;
77
+ const incomplete = { ...f.values, [keys.at(-1)]: '' };
78
+ const client = f.client(async () => ++calls === 1 ? { SecretString: JSON.stringify(incomplete) } : f.response());
79
+ await assert.rejects(f.loader.loadRuntimeSecrets({ client }));
80
+ for (const key of keys) assert.equal(f.env[key], 'old-synthetic-value');
81
+ assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
82
+ });
83
+ test(file + ': development fallback does not prevent a later successful fetch', async () => {
84
+ const f = fixture(file, { RUNTIME_SECRET_ID: '/synthetic/secret' });
85
+ let calls = 0;
86
+ const client = f.client(async () => { if (++calls === 1) throw new Error('synthetic denied'); return f.response(); });
87
+ try { await f.loader.loadRuntimeSecrets({ client }); } catch (error) { assert.match(error.message, /synthetic denied/); }
88
+ assert.equal((await f.loader.loadRuntimeSecrets({ client })).source, 'secrets_manager');
89
+ assert.equal(calls, 2);
90
+ });
91
+ }
@@ -0,0 +1,16 @@
1
+ # Runtime secret loading and credential cleanup
2
+
3
+ Successful runtime secrets are loaded once, before configuration, clients, and
4
+ handlers initialize. Values remain cached until process replacement. Failed
5
+ fetches and development fallback results are not cached, so a later call can
6
+ retry; concurrent calls share one pending fetch. AWS SDK retries are bounded
7
+ (three attempts). Invalid secret contents never partially update configuration.
8
+
9
+ Production (`NODE_ENV=production`), deployed Lambda runtimes, and any runtime
10
+ with `RUNTIME_SECRET_REQUIRED=true` require an explicit, nonblank
11
+ `RUNTIME_SECRET_ID`. Setting the required flag to false cannot enable legacy
12
+ fallback in production or Lambda. Explicit development defaults remain unchanged.
13
+
14
+ See the [shared restart and cleanup runbook](https://github.com/Totem-VC/totem-services/blob/main/infrastructure/security-checks/runtime-secrets.md) for release verification, credential cleanup, and remaining limitations.
15
+
16
+ Run `node --test infrastructure/security-checks/runtime-secret-contract.cjs` for isolated loader checks. These source changes require the normal application release to affect running processes. No credential was rotated.
package/lib/helpers.js CHANGED
@@ -43,6 +43,10 @@ var mergeObjects = function mergeObjects(target, source, doNotMergeKeys) {
43
43
 
44
44
  for (var key in jsonSource) {
45
45
 
46
+ // Never follow prototype paths or merge inherited source properties.
47
+ // Check before reading target[key], which may resolve to a prototype.
48
+ if (!Object.prototype.hasOwnProperty.call(jsonSource, key)) continue;
49
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
46
50
  if (doNotMergeKeys.indexOf(key) >= 0) continue;
47
51
 
48
52
  var sourceItem = jsonSource[key];
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+
3
+ var SecretsManager = require('@aws-sdk/client-secrets-manager');
4
+ var SECRET_ID = '/totem/dev/totem-merge';
5
+ var loadPromise;
6
+
7
+ // Production and deployed Lambda runtimes must never use legacy credentials.
8
+ const secretsRequired = () => process.env.RUNTIME_SECRET_REQUIRED === 'true' ||
9
+ process.env.NODE_ENV === 'production' || Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
10
+
11
+ function applySecret(response) {
12
+ var values = JSON.parse(response.SecretString || Buffer.from(response.SecretBinary).toString('utf8'));
13
+ if (!values.DB_URI || typeof values.DB_URI !== 'string') throw new Error('Runtime secret missing DB_URI');
14
+ process.env.DB_URI = values.DB_URI;
15
+ return { source: 'secrets_manager' };
16
+ }
17
+
18
+ async function load(client) {
19
+ var secretId = (process.env.RUNTIME_SECRET_ID || '').trim();
20
+ if (!secretId && secretsRequired()) throw new Error('RUNTIME_SECRET_ID is required for this runtime');
21
+ secretId = secretId || SECRET_ID;
22
+ return applySecret(await client.send(new SecretsManager.GetSecretValueCommand({ SecretId: secretId })));
23
+ }
24
+
25
+ function loadRuntimeSecrets(options) {
26
+ if (!loadPromise) {
27
+ const client = (options && options.client) || new SecretsManager.SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1', maxAttempts: 3 });
28
+ // Cache successful loads until process replacement. Rejected/legacy loads
29
+ // are retried on the next call; concurrent callers share this attempt.
30
+ loadPromise = load(client).then(result => {
31
+ if (result.source !== 'secrets_manager') loadPromise = undefined;
32
+ return result;
33
+ }, error => {
34
+ loadPromise = undefined;
35
+ throw error;
36
+ });
37
+ }
38
+ return loadPromise;
39
+ }
40
+
41
+ function resetForTest() { loadPromise = undefined; }
42
+
43
+ module.exports = { SECRET_ID: SECRET_ID, applySecret: applySecret,
44
+ loadRuntimeSecrets: loadRuntimeSecrets, resetForTest: resetForTest };
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@dhyasama/totem-merge",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "description": "Org and person merging package",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "prestart": "node ./lib/changeBSON",
8
- "start": "NODE_ENV=test node ./cli",
7
+ "start": "node ./bin/bootstrap",
9
8
  "test": "NODE_ENV=test mocha -R spec -t 10000"
10
9
  },
11
10
  "publishConfig": {
@@ -16,7 +15,8 @@
16
15
  "node": ">=24 <25"
17
16
  },
18
17
  "dependencies": {
19
- "@dhyasama/totem-logger": "^2.1.0",
18
+ "@aws-sdk/client-secrets-manager": "^3.883.0",
19
+ "@dhyasama/totem-logger": "3.0.0",
20
20
  "@dhyasama/totem-utilities": "^4.0.0",
21
21
  "async": "^2.6.4",
22
22
  "commander": "^2.20.3",
@@ -24,12 +24,21 @@
24
24
  "underscore": "^1.13.6"
25
25
  },
26
26
  "peerDependencies": {
27
- "mongoose": "^8.23.0"
27
+ "mongoose": "^8.24.3"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@dhyasama/totem-models": "^12.14.0",
31
- "mocha": "^5.2.0",
31
+ "mocha": "^12.0.2",
32
32
  "mocha-mongoose": "^1.1.1",
33
33
  "should": "^8.2.2"
34
+ },
35
+ "overrides": {
36
+ "r7insight_node": {
37
+ "lodash": "4.18.1",
38
+ "semver": "5.7.2",
39
+ "codependency": {
40
+ "semver": "5.7.2"
41
+ }
42
+ }
34
43
  }
35
44
  }
package/test/config.js CHANGED
@@ -7,7 +7,7 @@ module.exports = {
7
7
  LPS_ENABLED: false,
8
8
 
9
9
  db: {
10
- uri: 'mongodb://gZvwpF5aLFvdc7KFa7qLLiZYv6CTLdfs:TKfdi3LD4583f8EL11k5Y0uukZ151d9n@test-shard-00-00-gv0f3.mongodb.net:27017,test-shard-00-01-gv0f3.mongodb.net:27017,test-shard-00-02-gv0f3.mongodb.net:27017/test?ssl=true&replicaSet=Test-shard-0&authSource=admin'
10
+ uri: process.env.TEST_DB_URI || 'mongodb://127.0.0.1:27017/totem-merge-test'
11
11
  },
12
12
 
13
13
  crypto: {
package/test/index.js CHANGED
@@ -2,11 +2,7 @@
2
2
 
3
3
  var config = require('./config');
4
4
  var dbUri = config.db.uri;
5
-
6
- if (dbUri.indexOf('@test-shard-') === -1) {
7
- console.log('Not a test db. Quitting...');
8
- process.exit();
9
- }
5
+ var describeMerge = process.env.TEST_DB_URI ? describe : describe.skip;
10
6
 
11
7
  var should = require("should");
12
8
  var mongoose = require('mongoose');
@@ -26,7 +22,7 @@ var customer;
26
22
  var org1, org2, org3, org4, org5, org6;
27
23
  var person1, person2, person3;
28
24
 
29
- describe('Merge', function() {
25
+ describeMerge('Merge', function() {
30
26
 
31
27
  // connect to test db
32
28
  before(async function() {
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ var assert = require('node:assert/strict');
4
+ var mongoose = require('mongoose');
5
+ var mergeObjects = require('../lib/helpers').mergeObjects;
6
+
7
+ describe('mergeObjects security and compatibility', function() {
8
+ it('does not traverse __proto__ at the root or inside nested objects', function() {
9
+ [false, true].forEach(function(nested) {
10
+ var prototype = { mergeSecurityMarker: null };
11
+ var object = Object.create(prototype);
12
+ var payload = JSON.parse('{"__proto__":{"mergeSecurityMarker":"changed"}}');
13
+ mergeObjects(nested ? { profile: object } : object,
14
+ nested ? { profile: payload } : payload, []);
15
+ assert.equal(prototype.mergeSecurityMarker, null);
16
+ assert.equal(Object.getPrototypeOf(object), prototype);
17
+ });
18
+ });
19
+
20
+ it('does not write through Object.prototype', function() {
21
+ var key = '__totemMergeSecurityTest';
22
+ assert.equal(Object.hasOwn(Object.prototype, key), false);
23
+ Object.defineProperty(Object.prototype, key, {
24
+ value: null, writable: true, configurable: true
25
+ });
26
+ try {
27
+ mergeObjects({}, JSON.parse('{"__proto__":{"__totemMergeSecurityTest":true}}'), []);
28
+ assert.equal(Object.prototype[key], null);
29
+ assert.equal(({})[key], null);
30
+ } finally {
31
+ delete Object.prototype[key];
32
+ }
33
+ });
34
+
35
+ it('ignores constructor and prototype keys at each recursive level', function() {
36
+ var target = {
37
+ constructor: { prototype: { marker: null } },
38
+ prototype: { marker: null },
39
+ profile: { constructor: null, prototype: null, label: null }
40
+ };
41
+ var source = JSON.parse('{"constructor":{"prototype":{"marker":"bad"}},' +
42
+ '"prototype":{"marker":"bad"},' +
43
+ '"profile":{"constructor":"bad","prototype":"bad","label":"allowed"}}');
44
+ mergeObjects(target, source, []);
45
+ assert.equal(target.constructor.prototype.marker, null);
46
+ assert.equal(target.prototype.marker, null);
47
+ assert.deepEqual(target.profile, { constructor: null, prototype: null, label: 'allowed' });
48
+ });
49
+
50
+ it('ignores inherited source properties returned by a document conversion', function() {
51
+ var plain = Object.create({ inherited: 'not source data' });
52
+ plain.name = 'Allowed';
53
+ var target = { inherited: null, name: null };
54
+ mergeObjects(target, { toObject: function() { return plain; } }, []);
55
+ assert.deepEqual(target, { inherited: null, name: 'Allowed' });
56
+ });
57
+
58
+ it('preserves target precedence, exclusions, falsy values and source data', function() {
59
+ var target = {
60
+ name: 'Keep', description: null, enabled: false, count: 0,
61
+ empty: '', omitted: null, zero: null, disabled: null,
62
+ profile: { label: null, retained: 'Keep nested', omitted: null }
63
+ };
64
+ var source = {
65
+ name: 'Replace', description: 'Fill', enabled: true, count: 10,
66
+ empty: 'Replace', omitted: 'Excluded', zero: 0, disabled: false,
67
+ missing: 'Do not introduce',
68
+ profile: { label: 'Fill nested', retained: 'Replace', omitted: 'Excluded' }
69
+ };
70
+ var before = JSON.stringify(source);
71
+ assert.equal(mergeObjects(target, source, ['omitted']), target);
72
+ assert.deepEqual(target, {
73
+ name: 'Keep', description: 'Fill', enabled: false, count: 0,
74
+ empty: '', omitted: null, zero: null, disabled: null,
75
+ profile: { label: 'Fill nested', retained: 'Keep nested', omitted: null }
76
+ });
77
+ assert.equal(JSON.stringify(source), before);
78
+ });
79
+
80
+ it('preserves array ordering, ObjectId deduplication and people/phone keys', function() {
81
+ var first = new mongoose.Types.ObjectId();
82
+ var second = new mongoose.Types.ObjectId();
83
+ var target = {
84
+ aliases: ['First'], refs: [first],
85
+ people: [{ person: first, title: 'Keep' }],
86
+ phone: [{ number: '(212) 555-0100', label: 'Keep' }]
87
+ };
88
+ mergeObjects(target, {
89
+ aliases: ['First', 'Second'], refs: [first, second],
90
+ people: [{ person: first, title: 'Replace' }, { person: second, title: 'New' }],
91
+ phone: [{ number: '2125550100', label: 'Replace' }, { number: '2125550101', label: 'New' }]
92
+ }, []);
93
+ assert.deepEqual(target.aliases, ['First', 'Second']);
94
+ assert.deepEqual(target.refs.map(String), [String(first), String(second)]);
95
+ assert.equal(target.people.length, 2);
96
+ assert.equal(target.people[0].title, 'Keep');
97
+ assert.equal(target.people[1].title, 'New');
98
+ assert.equal(target.phone.length, 2);
99
+ assert.equal(target.phone[0].label, 'Keep');
100
+ });
101
+
102
+ it('merges real Mongoose documents without invoking virtual getters', function() {
103
+ var isolated = new mongoose.Mongoose();
104
+ var schema = new isolated.Schema({
105
+ name: String, description: { type: String, default: null }, aliases: [String],
106
+ profile: { label: { type: String, default: null } }
107
+ });
108
+ schema.virtual('unsafeVirtual').get(function() { throw new Error('Virtual invoked'); });
109
+ var Model = isolated.model('MergeSecurityFixture', schema);
110
+ var target = new Model({ name: 'Keep', aliases: ['First'] });
111
+ var source = new Model({ name: 'Replace', description: 'Fill', aliases: ['First', 'Second'], profile: { label: 'Nested' } });
112
+ mergeObjects(target, source, ['_id', '__v']);
113
+ assert.equal(target.name, 'Keep');
114
+ assert.equal(target.description, 'Fill');
115
+ assert.deepEqual(Array.from(target.aliases), ['First', 'Second']);
116
+ assert.equal(target.profile.label, 'Nested');
117
+ assert.equal(source.name, 'Replace');
118
+ });
119
+ });
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+
3
+ var assert = require('node:assert/strict');
4
+ var fs = require('node:fs');
5
+ var path = require('node:path');
6
+ var runtimeSecrets = require('../lib/runtimeSecrets');
7
+
8
+ describe('totem-merge runtime secrets', function() {
9
+ afterEach(function() { runtimeSecrets.resetForTest(); });
10
+
11
+ it('loads DB_URI from the dedicated development secret', async function() {
12
+ var client = { send: async function(command) {
13
+ assert.equal(command.input.SecretId, runtimeSecrets.SECRET_ID);
14
+ return { SecretString: JSON.stringify({ DB_URI: 'mongodb://test-host/test' }) };
15
+ } };
16
+ delete process.env.RUNTIME_SECRET_ID;
17
+ await runtimeSecrets.loadRuntimeSecrets({ client: client });
18
+ assert.equal(process.env.DB_URI, 'mongodb://test-host/test');
19
+ });
20
+
21
+ it('contains no credentialed MongoDB URI in source configuration', function() {
22
+ var root = path.join(__dirname, '..');
23
+ ['config.js', 'test/config.js'].forEach(function(file) {
24
+ assert.doesNotMatch(fs.readFileSync(path.join(root, file), 'utf8'), /mongodb(?:\+srv)?:\/\/[^\s]+:[^\s]+@/);
25
+ });
26
+ });
27
+ });
package/lib/changeBSON.js DELETED
@@ -1,22 +0,0 @@
1
- "use strict";
2
-
3
- var fs = require('fs');
4
- var filePath = './node_modules/mongoose-post-find/node_modules/bson/ext/index.js';
5
- var config = require('./../config');
6
- var Logger = require('@dhyasama/totem-logger');
7
- var logger = new Logger(config.logentries);
8
-
9
- fs.readFile(filePath, 'utf8', function (err, data) {
10
-
11
- if (err) { logger.handleErr(err); }
12
- else {
13
-
14
- var result = data.replace("bson = require('../build/Release/bson');", "bson = require('bson');");
15
-
16
- fs.writeFile(filePath, result, 'utf8', function (err) {
17
- if (err) { logger.handleErr(err); }
18
- });
19
-
20
- }
21
-
22
- });