@drawbridge/drawbridge-utils 0.0.116 → 0.0.118

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.
@@ -0,0 +1,317 @@
1
+ import { mergeSettings } from './connections/index.cjs';
2
+ import { decrypt, encrypt } from './encrypt.cjs';
3
+ import './connections/oauth.cjs';
4
+ import 'node:crypto';
5
+ import './http.cjs';
6
+ import './pricing.cjs';
7
+ import './plans.cjs';
8
+ import './features.cjs';
9
+ import './index.cjs';
10
+ import 'currency-codes';
11
+ import 'nanoid';
12
+ import './color.cjs';
13
+ import 'tinycolor2';
14
+ import './usage.cjs';
15
+ import './billing.cjs';
16
+ import './transactions.cjs';
17
+ import '@drawbridge/drawbridge-telemetry';
18
+ import './safe-http.cjs';
19
+ import 'dns';
20
+ import 'node:http';
21
+ import 'node:https';
22
+ import './axios.cjs';
23
+ import 'axios';
24
+ import 'net';
25
+ import 'crypto';
26
+ import './token.cjs';
27
+
28
+ // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
29
+ //
30
+ // lib/connections is the merchant-facing catalogue; this is the platform side.
31
+ // They stay apart deliberately — a separate collection is only worth having if
32
+ // merchant data and platform secrets do not start sharing helpers.
33
+ //
34
+ // HERE RATHER THAN IN AN APP, because three of them need the same answer.
35
+ // drawbridge-api serves the admin screen and resolves the catalogue,
36
+ // drawbridge-sync refreshes OAuth tokens and sends every mail and SMS, and
37
+ // drawbridge-webhooks verifies vendor signatures. A copy per repo is three
38
+ // copies of a decrypt and three chances for one of them to read a credential
39
+ // the others have already rotated away.
40
+
41
+
42
+ // WHAT AN ADMIN TYPES IN, per vendor.
43
+ //
44
+ // `redact` marks a secret: never returned by the api, and blank on save means
45
+ // keep the stored value. `required` drives the live check. `input` picks the
46
+ // form control.
47
+ //
48
+ // These move onto the manifests themselves as `provider : { fields }` in the
49
+ // follow-up plan, at which point providerFields() delegates to the manifest and
50
+ // this constant goes. It is one table for now because a vendor's credentials and
51
+ // its manifest are declared in the same package either way.
52
+ const FIELDS = {
53
+ attentive : [
54
+ { input : 'text', key : 'clientId', env : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
55
+ { input : 'password', key : 'clientSecret', env : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
56
+ ],
57
+ drawbridge : [
58
+ { input : 'email', key : 'accountSender', env : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
59
+ { input : 'password', key : 'apiKey', env : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
60
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
61
+ // without a token — requiring it would make the whole drawbridge provider
62
+ // read not-live over something no merchant ever sees.
63
+ { input : 'password', key : 'hubspotToken', env : 'HUBSPOT_ACCESS_TOKEN', label : 'HubSpot access token', message : 'Drawbridge\'s own CRM portal. Internal — no merchant sees this.', redact : true },
64
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
65
+ // either. Unset, it degrades to the account sender rather than
66
+ // refusing to start.
67
+ { input : 'email', key : 'leadSender', env : 'SENDGRID_SEND_FROM_ADDRESS', label : 'Lead sender', message : 'The default for lead-facing mail when a merchant has not verified their own domain.' },
68
+ { input : 'text', key : 'smsFrom', env : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
69
+ { input : 'password', key : 'smsSid', env : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
70
+ { input : 'password', key : 'smsToken', env : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
71
+ ],
72
+ klaviyo : [
73
+ { input : 'text', key : 'clientId', env : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
74
+ { input : 'password', key : 'clientSecret', env : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
75
+ ],
76
+ mailchimp : [
77
+ { input : 'text', key : 'clientId', env : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
78
+ { input : 'password', key : 'clientSecret', env : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
79
+ ],
80
+ shopify : [
81
+ { input : 'text', key : 'apiKey', env : 'SHOPIFY_API_KEY', label : 'API key', required : true },
82
+ { input : 'password', key : 'apiSecret', env : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
83
+ { input : 'text', key : 'appHandle', env : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
84
+ { input : 'text', key : 'listingUrl', env : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
85
+ ]
86
+ };
87
+
88
+ // Object.hasOwn, not `FIELDS[ slug ] ||` — a bare property read walks the
89
+ // prototype chain, and FIELDS[ 'constructor' ] is a function whose .length is
90
+ // 1, so the route's "does this vendor exist" guard passed for it and reached
91
+ // the database before throwing.
92
+ const providerFields = ( slug ) => Object.hasOwn( FIELDS, slug ) ? FIELDS[ slug ] : [];
93
+
94
+ // The vendors an admin can configure, in a stable order.
95
+ const providerSlugs = () => Object.keys( FIELDS ).sort();
96
+
97
+ // LIVE IS DERIVED, never stored. A stored flag is a second thing to keep in
98
+ // sync and goes stale the moment a row is edited by hand.
99
+ const isLive = ( slug, settings ) => {
100
+
101
+ const fields = providerFields( slug );
102
+
103
+ if( ! fields.length ) return false;
104
+
105
+ return fields
106
+ .filter( ( field ) => field.required )
107
+ .every( ( field ) => Boolean( settings?.[ field.key ] ) );
108
+
109
+ };
110
+
111
+ // ENOUGH TO RECOGNISE A CREDENTIAL, never enough to use one.
112
+ //
113
+ // The value itself is never sent — this is computed here so the browser only
114
+ // ever holds the mask. That is stricter than route/oauth.js, which decrypts
115
+ // client secrets in full for its admin table, and the difference is deliberate:
116
+ // these are PLATFORM credentials, so one leaked key is every organisation's
117
+ // problem at once, and a value that reaches the browser reaches the query
118
+ // cache, devtools and any error report with it.
119
+ //
120
+ // Fixed-width dots rather than one per character, so the length does not travel
121
+ // either. Below the threshold a secret is short enough that a prefix and suffix
122
+ // would be most of it, so nothing is revealed.
123
+ const mask = ( value ) => {
124
+
125
+ if( ! value ) return null;
126
+
127
+ if( String( value ).length < 12 ) return '••••••••';
128
+
129
+ return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
130
+
131
+ };
132
+
133
+ // THE CACHE KEY, in one place. All three services share one Redis, so a bust
134
+ // from the api only clears sync's and webhooks' view if every app derives the
135
+ // same key. Three independent derivations is one that eventually disagrees, and
136
+ // that failure serves a credential that was already rotated away.
137
+ const cacheKey = ( slug ) => [ 'provider', slug ];
138
+
139
+ // Read a vendor's credentials. Cached, because this is on the path of every
140
+ // OAuth callback and every send.
141
+ //
142
+ // An unconfigured vendor answers {} rather than throwing: the admin list has to
143
+ // render the row that lets someone fix it.
144
+ //
145
+ // Thirty seconds rather than the default, so a credential rotated away stops
146
+ // being served quickly.
147
+ const providerSettings = async ({ cache, controller, slug }) => {
148
+
149
+ const read = async () => controller.get({
150
+ collection : 'provider',
151
+ query : { slug }
152
+ });
153
+
154
+ // OPTIONAL. drawbridge-webhooks has no Redis and reads these once at boot, so
155
+ // requiring a cache there would mean inventing one that never caches.
156
+ const row = cache ? await cache.use( cacheKey( slug ), read, 30 ) : await read();
157
+
158
+ return row?.settings ? decrypt( row.settings ) : {};
159
+
160
+ };
161
+
162
+ // Write a vendor's credentials.
163
+ //
164
+ // ONLY THE DECLARED FIELDS. The body arrives over the wire, so anything in it
165
+ // that no field declares is not ours to store — that is what keeps this route
166
+ // from being able to reach `slug` or any other column.
167
+ //
168
+ // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
169
+ // unchanged form posts it back empty; treating that as a clear would wipe a
170
+ // credential every time someone edited the field next to it.
171
+ const saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
172
+
173
+ const fields = providerFields( slug );
174
+
175
+ if( ! fields.length ) return null;
176
+
177
+ const existing = await controller.get({
178
+ collection : 'provider',
179
+ query : { slug }
180
+ });
181
+
182
+ const stored = existing?.settings ? decrypt( existing.settings ) : {};
183
+
184
+ // mergeSettings is the blank-keeps rule, already used for merchant
185
+ // connection settings in route/organization-connection.js — same behaviour,
186
+ // one implementation. The declared-field allowlist is the part that is ours:
187
+ // building `incoming` from `fields` is what stops anything the vendor does
188
+ // not declare reaching the document.
189
+ const merged = mergeSettings({
190
+ existing : stored,
191
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, settings?.[ field.key ] ] ) )
192
+ });
193
+
194
+ // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
195
+ // be the same gesture. Without this there is no way to remove a credential
196
+ // at all: every empty box is read as "leave it alone".
197
+ //
198
+ // Filtered against the declarations like everything else, so `clear` cannot
199
+ // reach a key the vendor does not have.
200
+ for( const key of ( Array.isArray( clear ) ? clear : [] ) ){
201
+
202
+ if( fields.some( ( field ) => field.key === key ) ) delete merged[ key ];
203
+
204
+ }
205
+
206
+ const result = await controller.update({
207
+ authenticated,
208
+ collection : 'provider',
209
+ data : {
210
+ $set : {
211
+ settings : encrypt( merged )
212
+ }
213
+ },
214
+ options : {
215
+ upsert : true
216
+ },
217
+ query : { slug }
218
+ });
219
+
220
+ // AFTER the write rather than before it, which is the better of two
221
+ // imperfect orderings — busting first would guarantee a reader repopulates
222
+ // the old value.
223
+ //
224
+ // It does NOT close the race. cache.use is GET-miss, run callback,
225
+ // unconditional JSON.SET, with no compare-and-set: a reader whose read
226
+ // already returned the old row, and whose write lands after this delete,
227
+ // puts the pre-rotation credential back. The short TTL on the read is what
228
+ // bounds that — seconds, not the default — because this is the Redis all
229
+ // three services share and the value is a credential someone may have just
230
+ // rotated BECAUSE it leaked.
231
+ await cache.delete( cacheKey( slug ) );
232
+
233
+ return result;
234
+
235
+ };
236
+
237
+ // EVERY STORED CREDENTIAL, SHAPED LIKE AN ENVIRONMENT.
238
+ //
239
+ // Keyed by the env var each field replaces, because that is the shape the
240
+ // manifests already speak: `requires` names env vars, and auth.oauth.client
241
+ // names them too. Handing availableConnections this map instead of process.env
242
+ // is the whole switch — the package does not change, and 137 call sites in this
243
+ // repo keep reading a plain synchronous object.
244
+ //
245
+ // A field with no value is OMITTED rather than set empty, so `requires` sees
246
+ // the same absence it would for an unset variable.
247
+ // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
248
+ //
249
+ // The boundary between "an admin types this in" and "the deployment supplies
250
+ // it". ENCRYPT_CONNECTION_SECRET is the clearest case of the latter: the webhook
251
+ // connection requires it, and it can never be stored here because it is the key
252
+ // that decrypts this collection. Names outside this set keep coming from the
253
+ // environment.
254
+ const providerEnvNames = () => new Set(
255
+ providerSlugs()
256
+ .flatMap( ( slug ) => providerFields( slug ) )
257
+ .map( ( field ) => field.env )
258
+ .filter( Boolean )
259
+ );
260
+
261
+ const providerCredentials = async ({ cache, controller }) => {
262
+
263
+ const credentials = {};
264
+
265
+ for( const slug of providerSlugs() ){
266
+
267
+ const settings = await providerSettings({ cache, controller, slug });
268
+
269
+ for( const field of providerFields( slug ) ){
270
+
271
+ const value = settings?.[ field.key ];
272
+
273
+ if( field.env && value ) credentials[ field.env ] = value;
274
+
275
+ }
276
+
277
+ }
278
+
279
+ return credentials;
280
+
281
+ };
282
+
283
+ // PUT THE STORED CREDENTIALS WHERE EVERYTHING ALREADY LOOKS.
284
+ //
285
+ // The apps are not the only readers. @drawbridge/shopify verifies every webhook
286
+ // HMAC against process.env.SHOPIFY_API_SECRET, drawbridge-utils' own sendgrid
287
+ // and twilio wrappers default to process.env, and drawbridge-sync freezes its
288
+ // step→queue routing table from process.env at require time. Threading a
289
+ // credentials object through all of that would be a sweep across three repos and
290
+ // a published package, and every site it missed would fail silently.
291
+ //
292
+ // So the environment stays the transport and the collection becomes the SOURCE:
293
+ // this writes the stored values into process.env before anything reads them.
294
+ //
295
+ // IT DELETES FIRST. Every name a provider field declares is removed whether or
296
+ // not the database has a value for it, so a variable left behind in a deployment
297
+ // cannot keep a vendor alive after its credential was taken out of the database
298
+ // — which would leave the connection reading available and failing on use.
299
+ // Names no provider declares are untouched: ENCRYPT_CONNECTION_SECRET is the
300
+ // clearest case, since it is the key this collection is decrypted with and can
301
+ // never be stored inside it.
302
+ //
303
+ // Returns the names it set, so a caller can log what it loaded without logging
304
+ // what it loaded them to.
305
+ const hydrateEnvironment = async ({ cache, controller, env = process.env }) => {
306
+
307
+ const credentials = await providerCredentials({ cache, controller });
308
+
309
+ for( const name of providerEnvNames() ) delete env[ name ];
310
+
311
+ Object.assign( env, credentials );
312
+
313
+ return Object.keys( credentials ).sort();
314
+
315
+ };
316
+
317
+ export { cacheKey, hydrateEnvironment, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerSettings, providerSlugs, saveProviderSettings };
@@ -0,0 +1,317 @@
1
+ import { mergeSettings } from './connections/index.js';
2
+ import { decrypt, encrypt } from './encrypt.js';
3
+ import './connections/oauth.js';
4
+ import 'node:crypto';
5
+ import './http.js';
6
+ import './pricing.js';
7
+ import './plans.js';
8
+ import './features.js';
9
+ import './index.js';
10
+ import 'currency-codes';
11
+ import 'nanoid';
12
+ import './color.js';
13
+ import 'tinycolor2';
14
+ import './usage.js';
15
+ import './billing.js';
16
+ import './transactions.js';
17
+ import '@drawbridge/drawbridge-telemetry';
18
+ import './safe-http.js';
19
+ import 'dns';
20
+ import 'node:http';
21
+ import 'node:https';
22
+ import './axios.js';
23
+ import 'axios';
24
+ import 'net';
25
+ import 'crypto';
26
+ import './token.js';
27
+
28
+ // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
29
+ //
30
+ // lib/connections is the merchant-facing catalogue; this is the platform side.
31
+ // They stay apart deliberately — a separate collection is only worth having if
32
+ // merchant data and platform secrets do not start sharing helpers.
33
+ //
34
+ // HERE RATHER THAN IN AN APP, because three of them need the same answer.
35
+ // drawbridge-api serves the admin screen and resolves the catalogue,
36
+ // drawbridge-sync refreshes OAuth tokens and sends every mail and SMS, and
37
+ // drawbridge-webhooks verifies vendor signatures. A copy per repo is three
38
+ // copies of a decrypt and three chances for one of them to read a credential
39
+ // the others have already rotated away.
40
+
41
+
42
+ // WHAT AN ADMIN TYPES IN, per vendor.
43
+ //
44
+ // `redact` marks a secret: never returned by the api, and blank on save means
45
+ // keep the stored value. `required` drives the live check. `input` picks the
46
+ // form control.
47
+ //
48
+ // These move onto the manifests themselves as `provider : { fields }` in the
49
+ // follow-up plan, at which point providerFields() delegates to the manifest and
50
+ // this constant goes. It is one table for now because a vendor's credentials and
51
+ // its manifest are declared in the same package either way.
52
+ const FIELDS = {
53
+ attentive : [
54
+ { input : 'text', key : 'clientId', env : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
55
+ { input : 'password', key : 'clientSecret', env : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
56
+ ],
57
+ drawbridge : [
58
+ { input : 'email', key : 'accountSender', env : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
59
+ { input : 'password', key : 'apiKey', env : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
60
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
61
+ // without a token — requiring it would make the whole drawbridge provider
62
+ // read not-live over something no merchant ever sees.
63
+ { input : 'password', key : 'hubspotToken', env : 'HUBSPOT_ACCESS_TOKEN', label : 'HubSpot access token', message : 'Drawbridge\'s own CRM portal. Internal — no merchant sees this.', redact : true },
64
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
65
+ // either. Unset, it degrades to the account sender rather than
66
+ // refusing to start.
67
+ { input : 'email', key : 'leadSender', env : 'SENDGRID_SEND_FROM_ADDRESS', label : 'Lead sender', message : 'The default for lead-facing mail when a merchant has not verified their own domain.' },
68
+ { input : 'text', key : 'smsFrom', env : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
69
+ { input : 'password', key : 'smsSid', env : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
70
+ { input : 'password', key : 'smsToken', env : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
71
+ ],
72
+ klaviyo : [
73
+ { input : 'text', key : 'clientId', env : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
74
+ { input : 'password', key : 'clientSecret', env : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
75
+ ],
76
+ mailchimp : [
77
+ { input : 'text', key : 'clientId', env : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
78
+ { input : 'password', key : 'clientSecret', env : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
79
+ ],
80
+ shopify : [
81
+ { input : 'text', key : 'apiKey', env : 'SHOPIFY_API_KEY', label : 'API key', required : true },
82
+ { input : 'password', key : 'apiSecret', env : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
83
+ { input : 'text', key : 'appHandle', env : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
84
+ { input : 'text', key : 'listingUrl', env : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
85
+ ]
86
+ };
87
+
88
+ // Object.hasOwn, not `FIELDS[ slug ] ||` — a bare property read walks the
89
+ // prototype chain, and FIELDS[ 'constructor' ] is a function whose .length is
90
+ // 1, so the route's "does this vendor exist" guard passed for it and reached
91
+ // the database before throwing.
92
+ const providerFields = ( slug ) => Object.hasOwn( FIELDS, slug ) ? FIELDS[ slug ] : [];
93
+
94
+ // The vendors an admin can configure, in a stable order.
95
+ const providerSlugs = () => Object.keys( FIELDS ).sort();
96
+
97
+ // LIVE IS DERIVED, never stored. A stored flag is a second thing to keep in
98
+ // sync and goes stale the moment a row is edited by hand.
99
+ const isLive = ( slug, settings ) => {
100
+
101
+ const fields = providerFields( slug );
102
+
103
+ if( ! fields.length ) return false;
104
+
105
+ return fields
106
+ .filter( ( field ) => field.required )
107
+ .every( ( field ) => Boolean( settings?.[ field.key ] ) );
108
+
109
+ };
110
+
111
+ // ENOUGH TO RECOGNISE A CREDENTIAL, never enough to use one.
112
+ //
113
+ // The value itself is never sent — this is computed here so the browser only
114
+ // ever holds the mask. That is stricter than route/oauth.js, which decrypts
115
+ // client secrets in full for its admin table, and the difference is deliberate:
116
+ // these are PLATFORM credentials, so one leaked key is every organisation's
117
+ // problem at once, and a value that reaches the browser reaches the query
118
+ // cache, devtools and any error report with it.
119
+ //
120
+ // Fixed-width dots rather than one per character, so the length does not travel
121
+ // either. Below the threshold a secret is short enough that a prefix and suffix
122
+ // would be most of it, so nothing is revealed.
123
+ const mask = ( value ) => {
124
+
125
+ if( ! value ) return null;
126
+
127
+ if( String( value ).length < 12 ) return '••••••••';
128
+
129
+ return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
130
+
131
+ };
132
+
133
+ // THE CACHE KEY, in one place. All three services share one Redis, so a bust
134
+ // from the api only clears sync's and webhooks' view if every app derives the
135
+ // same key. Three independent derivations is one that eventually disagrees, and
136
+ // that failure serves a credential that was already rotated away.
137
+ const cacheKey = ( slug ) => [ 'provider', slug ];
138
+
139
+ // Read a vendor's credentials. Cached, because this is on the path of every
140
+ // OAuth callback and every send.
141
+ //
142
+ // An unconfigured vendor answers {} rather than throwing: the admin list has to
143
+ // render the row that lets someone fix it.
144
+ //
145
+ // Thirty seconds rather than the default, so a credential rotated away stops
146
+ // being served quickly.
147
+ const providerSettings = async ({ cache, controller, slug }) => {
148
+
149
+ const read = async () => controller.get({
150
+ collection : 'provider',
151
+ query : { slug }
152
+ });
153
+
154
+ // OPTIONAL. drawbridge-webhooks has no Redis and reads these once at boot, so
155
+ // requiring a cache there would mean inventing one that never caches.
156
+ const row = cache ? await cache.use( cacheKey( slug ), read, 30 ) : await read();
157
+
158
+ return row?.settings ? decrypt( row.settings ) : {};
159
+
160
+ };
161
+
162
+ // Write a vendor's credentials.
163
+ //
164
+ // ONLY THE DECLARED FIELDS. The body arrives over the wire, so anything in it
165
+ // that no field declares is not ours to store — that is what keeps this route
166
+ // from being able to reach `slug` or any other column.
167
+ //
168
+ // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
169
+ // unchanged form posts it back empty; treating that as a clear would wipe a
170
+ // credential every time someone edited the field next to it.
171
+ const saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
172
+
173
+ const fields = providerFields( slug );
174
+
175
+ if( ! fields.length ) return null;
176
+
177
+ const existing = await controller.get({
178
+ collection : 'provider',
179
+ query : { slug }
180
+ });
181
+
182
+ const stored = existing?.settings ? decrypt( existing.settings ) : {};
183
+
184
+ // mergeSettings is the blank-keeps rule, already used for merchant
185
+ // connection settings in route/organization-connection.js — same behaviour,
186
+ // one implementation. The declared-field allowlist is the part that is ours:
187
+ // building `incoming` from `fields` is what stops anything the vendor does
188
+ // not declare reaching the document.
189
+ const merged = mergeSettings({
190
+ existing : stored,
191
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, settings?.[ field.key ] ] ) )
192
+ });
193
+
194
+ // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
195
+ // be the same gesture. Without this there is no way to remove a credential
196
+ // at all: every empty box is read as "leave it alone".
197
+ //
198
+ // Filtered against the declarations like everything else, so `clear` cannot
199
+ // reach a key the vendor does not have.
200
+ for( const key of ( Array.isArray( clear ) ? clear : [] ) ){
201
+
202
+ if( fields.some( ( field ) => field.key === key ) ) delete merged[ key ];
203
+
204
+ }
205
+
206
+ const result = await controller.update({
207
+ authenticated,
208
+ collection : 'provider',
209
+ data : {
210
+ $set : {
211
+ settings : encrypt( merged )
212
+ }
213
+ },
214
+ options : {
215
+ upsert : true
216
+ },
217
+ query : { slug }
218
+ });
219
+
220
+ // AFTER the write rather than before it, which is the better of two
221
+ // imperfect orderings — busting first would guarantee a reader repopulates
222
+ // the old value.
223
+ //
224
+ // It does NOT close the race. cache.use is GET-miss, run callback,
225
+ // unconditional JSON.SET, with no compare-and-set: a reader whose read
226
+ // already returned the old row, and whose write lands after this delete,
227
+ // puts the pre-rotation credential back. The short TTL on the read is what
228
+ // bounds that — seconds, not the default — because this is the Redis all
229
+ // three services share and the value is a credential someone may have just
230
+ // rotated BECAUSE it leaked.
231
+ await cache.delete( cacheKey( slug ) );
232
+
233
+ return result;
234
+
235
+ };
236
+
237
+ // EVERY STORED CREDENTIAL, SHAPED LIKE AN ENVIRONMENT.
238
+ //
239
+ // Keyed by the env var each field replaces, because that is the shape the
240
+ // manifests already speak: `requires` names env vars, and auth.oauth.client
241
+ // names them too. Handing availableConnections this map instead of process.env
242
+ // is the whole switch — the package does not change, and 137 call sites in this
243
+ // repo keep reading a plain synchronous object.
244
+ //
245
+ // A field with no value is OMITTED rather than set empty, so `requires` sees
246
+ // the same absence it would for an unset variable.
247
+ // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
248
+ //
249
+ // The boundary between "an admin types this in" and "the deployment supplies
250
+ // it". ENCRYPT_CONNECTION_SECRET is the clearest case of the latter: the webhook
251
+ // connection requires it, and it can never be stored here because it is the key
252
+ // that decrypts this collection. Names outside this set keep coming from the
253
+ // environment.
254
+ const providerEnvNames = () => new Set(
255
+ providerSlugs()
256
+ .flatMap( ( slug ) => providerFields( slug ) )
257
+ .map( ( field ) => field.env )
258
+ .filter( Boolean )
259
+ );
260
+
261
+ const providerCredentials = async ({ cache, controller }) => {
262
+
263
+ const credentials = {};
264
+
265
+ for( const slug of providerSlugs() ){
266
+
267
+ const settings = await providerSettings({ cache, controller, slug });
268
+
269
+ for( const field of providerFields( slug ) ){
270
+
271
+ const value = settings?.[ field.key ];
272
+
273
+ if( field.env && value ) credentials[ field.env ] = value;
274
+
275
+ }
276
+
277
+ }
278
+
279
+ return credentials;
280
+
281
+ };
282
+
283
+ // PUT THE STORED CREDENTIALS WHERE EVERYTHING ALREADY LOOKS.
284
+ //
285
+ // The apps are not the only readers. @drawbridge/shopify verifies every webhook
286
+ // HMAC against process.env.SHOPIFY_API_SECRET, drawbridge-utils' own sendgrid
287
+ // and twilio wrappers default to process.env, and drawbridge-sync freezes its
288
+ // step→queue routing table from process.env at require time. Threading a
289
+ // credentials object through all of that would be a sweep across three repos and
290
+ // a published package, and every site it missed would fail silently.
291
+ //
292
+ // So the environment stays the transport and the collection becomes the SOURCE:
293
+ // this writes the stored values into process.env before anything reads them.
294
+ //
295
+ // IT DELETES FIRST. Every name a provider field declares is removed whether or
296
+ // not the database has a value for it, so a variable left behind in a deployment
297
+ // cannot keep a vendor alive after its credential was taken out of the database
298
+ // — which would leave the connection reading available and failing on use.
299
+ // Names no provider declares are untouched: ENCRYPT_CONNECTION_SECRET is the
300
+ // clearest case, since it is the key this collection is decrypted with and can
301
+ // never be stored inside it.
302
+ //
303
+ // Returns the names it set, so a caller can log what it loaded without logging
304
+ // what it loaded them to.
305
+ const hydrateEnvironment = async ({ cache, controller, env = process.env }) => {
306
+
307
+ const credentials = await providerCredentials({ cache, controller });
308
+
309
+ for( const name of providerEnvNames() ) delete env[ name ];
310
+
311
+ Object.assign( env, credentials );
312
+
313
+ return Object.keys( credentials ).sort();
314
+
315
+ };
316
+
317
+ export { cacheKey, hydrateEnvironment, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerSettings, providerSlugs, saveProviderSettings };