@drawbridge/drawbridge-utils 0.0.117 → 0.0.121

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,326 @@
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 './email.cjs';
19
+ import './phone.cjs';
20
+ import 'libphonenumber-js';
21
+ import './safe-http.cjs';
22
+ import 'dns';
23
+ import 'node:http';
24
+ import 'node:https';
25
+ import './axios.cjs';
26
+ import 'axios';
27
+ import 'net';
28
+ import 'crypto';
29
+ import './token.cjs';
30
+
31
+ // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
32
+ //
33
+ // lib/connections is the merchant-facing catalogue; this is the platform side.
34
+ // They stay apart deliberately — a separate collection is only worth having if
35
+ // merchant data and platform secrets do not start sharing helpers.
36
+ //
37
+ // HERE RATHER THAN IN AN APP, because three of them need the same answer.
38
+ // drawbridge-api serves the admin screen and resolves the catalogue,
39
+ // drawbridge-sync refreshes OAuth tokens and sends every mail and SMS, and
40
+ // drawbridge-webhooks verifies vendor signatures. A copy per repo is three
41
+ // copies of a decrypt and three chances for one of them to read a credential
42
+ // the others have already rotated away.
43
+
44
+
45
+ // WHAT AN ADMIN TYPES IN, per vendor.
46
+ //
47
+ // `redact` marks a secret: never returned by the api, and blank on save means
48
+ // keep the stored value. `required` drives the live check. `input` picks the
49
+ // form control.
50
+ //
51
+ // These move onto the manifests themselves as `provider : { fields }` in the
52
+ // follow-up plan, at which point providerFields() delegates to the manifest and
53
+ // this constant goes. It is one table for now because a vendor's credentials and
54
+ // its manifest are declared in the same package either way.
55
+ const FIELDS = {
56
+ attentive : [
57
+ { input : 'text', key : 'clientId', env : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
58
+ { input : 'password', key : 'clientSecret', env : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
59
+ ],
60
+ drawbridge : [
61
+ { input : 'email', key : 'accountSender', env : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
62
+ { input : 'password', key : 'apiKey', env : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
63
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
64
+ // without a token — requiring it would make the whole drawbridge provider
65
+ // read not-live over something no merchant ever sees.
66
+ { 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 },
67
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
68
+ // either. Unset, it degrades to the account sender rather than
69
+ // refusing to start.
70
+ { 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.' },
71
+ { input : 'text', key : 'smsFrom', env : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
72
+ { input : 'password', key : 'smsSid', env : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
73
+ { input : 'password', key : 'smsToken', env : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
74
+ ],
75
+ klaviyo : [
76
+ { input : 'text', key : 'clientId', env : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
77
+ { input : 'password', key : 'clientSecret', env : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
78
+ ],
79
+ mailchimp : [
80
+ { input : 'text', key : 'clientId', env : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
81
+ { input : 'password', key : 'clientSecret', env : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
82
+ ],
83
+ shopify : [
84
+ { input : 'text', key : 'apiKey', env : 'SHOPIFY_API_KEY', label : 'API key', required : true },
85
+ { input : 'password', key : 'apiSecret', env : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
86
+ { input : 'text', key : 'appHandle', env : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
87
+ { input : 'text', key : 'listingUrl', env : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
88
+ ]
89
+ };
90
+
91
+ // Object.hasOwn, not `FIELDS[ slug ] ||` — a bare property read walks the
92
+ // prototype chain, and FIELDS[ 'constructor' ] is a function whose .length is
93
+ // 1, so the route's "does this vendor exist" guard passed for it and reached
94
+ // the database before throwing.
95
+ const providerFields = ( slug ) => Object.hasOwn( FIELDS, slug ) ? FIELDS[ slug ] : [];
96
+
97
+ // The vendors an admin can configure, in a stable order.
98
+ const providerSlugs = () => Object.keys( FIELDS ).sort();
99
+
100
+ // LIVE IS DERIVED, never stored. A stored flag is a second thing to keep in
101
+ // sync and goes stale the moment a row is edited by hand.
102
+ const isLive = ( slug, settings ) => {
103
+
104
+ const fields = providerFields( slug );
105
+
106
+ if( ! fields.length ) return false;
107
+
108
+ return fields
109
+ .filter( ( field ) => field.required )
110
+ .every( ( field ) => Boolean( settings?.[ field.key ] ) );
111
+
112
+ };
113
+
114
+ // ENOUGH TO RECOGNISE A CREDENTIAL, never enough to use one.
115
+ //
116
+ // The value itself is never sent — this is computed here so the browser only
117
+ // ever holds the mask. That is stricter than route/oauth.js, which decrypts
118
+ // client secrets in full for its admin table, and the difference is deliberate:
119
+ // these are PLATFORM credentials, so one leaked key is every organisation's
120
+ // problem at once, and a value that reaches the browser reaches the query
121
+ // cache, devtools and any error report with it.
122
+ //
123
+ // Fixed-width dots rather than one per character, so the length does not travel
124
+ // either. Below the threshold a secret is short enough that a prefix and suffix
125
+ // would be most of it, so nothing is revealed.
126
+ //
127
+ // SIXTEEN, not twelve. The reveal is 3 + 4 = 7 characters, so a twelve-character
128
+ // value handed back more than half of itself while passing a check whose whole
129
+ // point was that it did not — the threshold has to be comfortably more than
130
+ // twice what is shown for the reasoning above to hold.
131
+ const mask = ( value ) => {
132
+
133
+ if( ! value ) return null;
134
+
135
+ if( String( value ).length < 16 ) return '••••••••';
136
+
137
+ return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
138
+
139
+ };
140
+
141
+ // A MEMO, NOT A CACHE, and the difference is the whole point. Shared Redis made
142
+ // one process's view of a credential another process's problem: a bust from the
143
+ // api had to reach sync and webhooks before either served a key someone had just
144
+ // rotated because it leaked, and cache.use has no compare-and-set to make that
145
+ // safe. Per-process state cannot be stale for anyone but the process holding it,
146
+ // and it expires on its own.
147
+ //
148
+ // slug -> { at, value }, where value is the DECRYPTED settings. Exported only so
149
+ // tests can age an entry without sleeping; nothing else should read it.
150
+ const providerMemo = new Map();
151
+
152
+ // SIXTY SECONDS, chosen for the human rather than for the load. The read is one
153
+ // indexed document and a decrypt — near enough free that memoizing it longer
154
+ // buys nothing worth the staleness. The moment that actually matters is the one
155
+ // right after an admin first types a credential in and goes looking for the
156
+ // vendor to come alive: they are watching, and a minute is the longest that wait
157
+ // should ever be.
158
+ const MEMO_TTL_MS = 60 * 1000;
159
+
160
+ // For tests. A save clears its own slug; this clears everything.
161
+ const clearProviderMemo = () => providerMemo.clear();
162
+
163
+ // Read a vendor's credentials, memoized, because this is on the path of every
164
+ // OAuth callback and every send.
165
+ //
166
+ // An unconfigured vendor answers {} rather than throwing: the admin list has to
167
+ // render the row that lets someone fix it.
168
+ //
169
+ // The memoized object is handed to every reader inside the window rather than
170
+ // copied per read — no caller mutates its credentials, and defending against one
171
+ // that does not exist would cost a clone on every send.
172
+ const providerSettings = async ({ controller, slug }) => {
173
+
174
+ const memoized = providerMemo.get( slug );
175
+
176
+ if( memoized && Date.now() - memoized.at < MEMO_TTL_MS ) return memoized.value;
177
+
178
+ const row = await controller.get({
179
+ collection : 'provider',
180
+ query : { slug }
181
+ });
182
+
183
+ const value = row?.settings ? decrypt( row.settings ) : {};
184
+
185
+ providerMemo.set( slug, { at : Date.now(), value });
186
+
187
+ return value;
188
+
189
+ };
190
+
191
+ // Write a vendor's credentials.
192
+ //
193
+ // ONLY THE DECLARED FIELDS. The body arrives over the wire, so anything in it
194
+ // that no field declares is not ours to store — that is what keeps this route
195
+ // from being able to reach `slug` or any other column.
196
+ //
197
+ // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
198
+ // unchanged form posts it back empty; treating that as a clear would wipe a
199
+ // credential every time someone edited the field next to it.
200
+ const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
201
+
202
+ const fields = providerFields( slug );
203
+
204
+ if( ! fields.length ) return null;
205
+
206
+ const existing = await controller.get({
207
+ collection : 'provider',
208
+ query : { slug }
209
+ });
210
+
211
+ const stored = existing?.settings ? decrypt( existing.settings ) : {};
212
+
213
+ // mergeSettings is the blank-keeps rule, already used for merchant
214
+ // connection settings in route/organization-connection.js — same behaviour,
215
+ // one implementation. The declared-field allowlist is the part that is ours:
216
+ // building `incoming` from `fields` is what stops anything the vendor does
217
+ // not declare reaching the document.
218
+ const merged = mergeSettings({
219
+ existing : stored,
220
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, settings?.[ field.key ] ] ) )
221
+ });
222
+
223
+ // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
224
+ // be the same gesture. Without this there is no way to remove a credential
225
+ // at all: every empty box is read as "leave it alone".
226
+ //
227
+ // Filtered against the declarations like everything else, so `clear` cannot
228
+ // reach a key the vendor does not have.
229
+ for( const key of ( Array.isArray( clear ) ? clear : [] ) ){
230
+
231
+ if( fields.some( ( field ) => field.key === key ) ) delete merged[ key ];
232
+
233
+ }
234
+
235
+ const result = await controller.update({
236
+ authenticated,
237
+ collection : 'provider',
238
+ data : {
239
+ $set : {
240
+ settings : encrypt( merged )
241
+ }
242
+ },
243
+ options : {
244
+ upsert : true
245
+ },
246
+ query : { slug }
247
+ });
248
+
249
+ // THE PROCESS THAT TOOK THE SAVE IS CORRECT IMMEDIATELY. Without this the
250
+ // admin who just typed the credential in reads back the list this same
251
+ // process memoized a moment ago and sees the vendor still not live, for up to
252
+ // a minute, with nothing to do about it.
253
+ //
254
+ // The other processes are not covered and do not need to be: their memos age
255
+ // out on their own within the window, which is the trade a per-process memo
256
+ // buys in exchange for never needing a bust to travel between services.
257
+ providerMemo.delete( slug );
258
+
259
+ return result;
260
+
261
+ };
262
+
263
+ // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
264
+ //
265
+ // The boundary between "an admin types this in" and "the deployment supplies
266
+ // it". ENCRYPT_CONNECTION_SECRET is the clearest case of the latter: the webhook
267
+ // connection requires it, and it can never be stored here because it is the key
268
+ // that decrypts this collection. Names outside this set keep coming from the
269
+ // environment.
270
+ const providerEnvNames = () => new Set(
271
+ providerSlugs()
272
+ .flatMap( ( slug ) => providerFields( slug ) )
273
+ .map( ( field ) => field.env )
274
+ .filter( Boolean )
275
+ );
276
+
277
+ // EVERY STORED CREDENTIAL, SHAPED LIKE AN ENVIRONMENT.
278
+ //
279
+ // Keyed by the env var each field replaces, because that is the shape the
280
+ // manifests already speak: `requires` names env vars, and auth.oauth.client
281
+ // names them too. Handing availableConnections this map instead of process.env
282
+ // is the whole switch — the package does not change, and 137 call sites in this
283
+ // repo keep reading a plain synchronous object.
284
+ //
285
+ // A field with no value is OMITTED rather than set empty, so `requires` sees
286
+ // the same absence it would for an unset variable.
287
+ const providerCredentials = async ({ controller }) => {
288
+
289
+ const credentials = {};
290
+
291
+ for( const slug of providerSlugs() ){
292
+
293
+ // ONE BAD ROW COSTS ONE VENDOR. decrypt throws on a value that was written
294
+ // under a different ENCRYPT_CONNECTION_SECRET, and unguarded that throw
295
+ // escaped the whole loop — a single half-rotated row read as a platform
296
+ // with no SendGrid, no Twilio and no Shopify either, which is a far worse
297
+ // outage than the one it came from.
298
+ //
299
+ // Degrading to "that vendor is unconfigured" lands in a state the product
300
+ // already has an answer for: the admin screen renders the row as not live,
301
+ // which is exactly where someone goes to fix it.
302
+ try {
303
+
304
+ const settings = await providerSettings({ controller, slug });
305
+
306
+ for( const field of providerFields( slug ) ){
307
+
308
+ const value = settings?.[ field.key ];
309
+
310
+ if( field.env && value ) credentials[ field.env ] = value;
311
+
312
+ }
313
+
314
+ } catch {
315
+
316
+ continue;
317
+
318
+ }
319
+
320
+ }
321
+
322
+ return credentials;
323
+
324
+ };
325
+
326
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };
@@ -0,0 +1,326 @@
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 './email.js';
19
+ import './phone.js';
20
+ import 'libphonenumber-js';
21
+ import './safe-http.js';
22
+ import 'dns';
23
+ import 'node:http';
24
+ import 'node:https';
25
+ import './axios.js';
26
+ import 'axios';
27
+ import 'net';
28
+ import 'crypto';
29
+ import './token.js';
30
+
31
+ // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
32
+ //
33
+ // lib/connections is the merchant-facing catalogue; this is the platform side.
34
+ // They stay apart deliberately — a separate collection is only worth having if
35
+ // merchant data and platform secrets do not start sharing helpers.
36
+ //
37
+ // HERE RATHER THAN IN AN APP, because three of them need the same answer.
38
+ // drawbridge-api serves the admin screen and resolves the catalogue,
39
+ // drawbridge-sync refreshes OAuth tokens and sends every mail and SMS, and
40
+ // drawbridge-webhooks verifies vendor signatures. A copy per repo is three
41
+ // copies of a decrypt and three chances for one of them to read a credential
42
+ // the others have already rotated away.
43
+
44
+
45
+ // WHAT AN ADMIN TYPES IN, per vendor.
46
+ //
47
+ // `redact` marks a secret: never returned by the api, and blank on save means
48
+ // keep the stored value. `required` drives the live check. `input` picks the
49
+ // form control.
50
+ //
51
+ // These move onto the manifests themselves as `provider : { fields }` in the
52
+ // follow-up plan, at which point providerFields() delegates to the manifest and
53
+ // this constant goes. It is one table for now because a vendor's credentials and
54
+ // its manifest are declared in the same package either way.
55
+ const FIELDS = {
56
+ attentive : [
57
+ { input : 'text', key : 'clientId', env : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
58
+ { input : 'password', key : 'clientSecret', env : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
59
+ ],
60
+ drawbridge : [
61
+ { input : 'email', key : 'accountSender', env : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
62
+ { input : 'password', key : 'apiKey', env : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
63
+ // NOT required. The CRM sync is best-effort internal tooling and no-ops
64
+ // without a token — requiring it would make the whole drawbridge provider
65
+ // read not-live over something no merchant ever sees.
66
+ { 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 },
67
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
68
+ // either. Unset, it degrades to the account sender rather than
69
+ // refusing to start.
70
+ { 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.' },
71
+ { input : 'text', key : 'smsFrom', env : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
72
+ { input : 'password', key : 'smsSid', env : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
73
+ { input : 'password', key : 'smsToken', env : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
74
+ ],
75
+ klaviyo : [
76
+ { input : 'text', key : 'clientId', env : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
77
+ { input : 'password', key : 'clientSecret', env : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
78
+ ],
79
+ mailchimp : [
80
+ { input : 'text', key : 'clientId', env : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
81
+ { input : 'password', key : 'clientSecret', env : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
82
+ ],
83
+ shopify : [
84
+ { input : 'text', key : 'apiKey', env : 'SHOPIFY_API_KEY', label : 'API key', required : true },
85
+ { input : 'password', key : 'apiSecret', env : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
86
+ { input : 'text', key : 'appHandle', env : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
87
+ { input : 'text', key : 'listingUrl', env : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
88
+ ]
89
+ };
90
+
91
+ // Object.hasOwn, not `FIELDS[ slug ] ||` — a bare property read walks the
92
+ // prototype chain, and FIELDS[ 'constructor' ] is a function whose .length is
93
+ // 1, so the route's "does this vendor exist" guard passed for it and reached
94
+ // the database before throwing.
95
+ const providerFields = ( slug ) => Object.hasOwn( FIELDS, slug ) ? FIELDS[ slug ] : [];
96
+
97
+ // The vendors an admin can configure, in a stable order.
98
+ const providerSlugs = () => Object.keys( FIELDS ).sort();
99
+
100
+ // LIVE IS DERIVED, never stored. A stored flag is a second thing to keep in
101
+ // sync and goes stale the moment a row is edited by hand.
102
+ const isLive = ( slug, settings ) => {
103
+
104
+ const fields = providerFields( slug );
105
+
106
+ if( ! fields.length ) return false;
107
+
108
+ return fields
109
+ .filter( ( field ) => field.required )
110
+ .every( ( field ) => Boolean( settings?.[ field.key ] ) );
111
+
112
+ };
113
+
114
+ // ENOUGH TO RECOGNISE A CREDENTIAL, never enough to use one.
115
+ //
116
+ // The value itself is never sent — this is computed here so the browser only
117
+ // ever holds the mask. That is stricter than route/oauth.js, which decrypts
118
+ // client secrets in full for its admin table, and the difference is deliberate:
119
+ // these are PLATFORM credentials, so one leaked key is every organisation's
120
+ // problem at once, and a value that reaches the browser reaches the query
121
+ // cache, devtools and any error report with it.
122
+ //
123
+ // Fixed-width dots rather than one per character, so the length does not travel
124
+ // either. Below the threshold a secret is short enough that a prefix and suffix
125
+ // would be most of it, so nothing is revealed.
126
+ //
127
+ // SIXTEEN, not twelve. The reveal is 3 + 4 = 7 characters, so a twelve-character
128
+ // value handed back more than half of itself while passing a check whose whole
129
+ // point was that it did not — the threshold has to be comfortably more than
130
+ // twice what is shown for the reasoning above to hold.
131
+ const mask = ( value ) => {
132
+
133
+ if( ! value ) return null;
134
+
135
+ if( String( value ).length < 16 ) return '••••••••';
136
+
137
+ return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
138
+
139
+ };
140
+
141
+ // A MEMO, NOT A CACHE, and the difference is the whole point. Shared Redis made
142
+ // one process's view of a credential another process's problem: a bust from the
143
+ // api had to reach sync and webhooks before either served a key someone had just
144
+ // rotated because it leaked, and cache.use has no compare-and-set to make that
145
+ // safe. Per-process state cannot be stale for anyone but the process holding it,
146
+ // and it expires on its own.
147
+ //
148
+ // slug -> { at, value }, where value is the DECRYPTED settings. Exported only so
149
+ // tests can age an entry without sleeping; nothing else should read it.
150
+ const providerMemo = new Map();
151
+
152
+ // SIXTY SECONDS, chosen for the human rather than for the load. The read is one
153
+ // indexed document and a decrypt — near enough free that memoizing it longer
154
+ // buys nothing worth the staleness. The moment that actually matters is the one
155
+ // right after an admin first types a credential in and goes looking for the
156
+ // vendor to come alive: they are watching, and a minute is the longest that wait
157
+ // should ever be.
158
+ const MEMO_TTL_MS = 60 * 1000;
159
+
160
+ // For tests. A save clears its own slug; this clears everything.
161
+ const clearProviderMemo = () => providerMemo.clear();
162
+
163
+ // Read a vendor's credentials, memoized, because this is on the path of every
164
+ // OAuth callback and every send.
165
+ //
166
+ // An unconfigured vendor answers {} rather than throwing: the admin list has to
167
+ // render the row that lets someone fix it.
168
+ //
169
+ // The memoized object is handed to every reader inside the window rather than
170
+ // copied per read — no caller mutates its credentials, and defending against one
171
+ // that does not exist would cost a clone on every send.
172
+ const providerSettings = async ({ controller, slug }) => {
173
+
174
+ const memoized = providerMemo.get( slug );
175
+
176
+ if( memoized && Date.now() - memoized.at < MEMO_TTL_MS ) return memoized.value;
177
+
178
+ const row = await controller.get({
179
+ collection : 'provider',
180
+ query : { slug }
181
+ });
182
+
183
+ const value = row?.settings ? decrypt( row.settings ) : {};
184
+
185
+ providerMemo.set( slug, { at : Date.now(), value });
186
+
187
+ return value;
188
+
189
+ };
190
+
191
+ // Write a vendor's credentials.
192
+ //
193
+ // ONLY THE DECLARED FIELDS. The body arrives over the wire, so anything in it
194
+ // that no field declares is not ours to store — that is what keeps this route
195
+ // from being able to reach `slug` or any other column.
196
+ //
197
+ // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
198
+ // unchanged form posts it back empty; treating that as a clear would wipe a
199
+ // credential every time someone edited the field next to it.
200
+ const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
201
+
202
+ const fields = providerFields( slug );
203
+
204
+ if( ! fields.length ) return null;
205
+
206
+ const existing = await controller.get({
207
+ collection : 'provider',
208
+ query : { slug }
209
+ });
210
+
211
+ const stored = existing?.settings ? decrypt( existing.settings ) : {};
212
+
213
+ // mergeSettings is the blank-keeps rule, already used for merchant
214
+ // connection settings in route/organization-connection.js — same behaviour,
215
+ // one implementation. The declared-field allowlist is the part that is ours:
216
+ // building `incoming` from `fields` is what stops anything the vendor does
217
+ // not declare reaching the document.
218
+ const merged = mergeSettings({
219
+ existing : stored,
220
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, settings?.[ field.key ] ] ) )
221
+ });
222
+
223
+ // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
224
+ // be the same gesture. Without this there is no way to remove a credential
225
+ // at all: every empty box is read as "leave it alone".
226
+ //
227
+ // Filtered against the declarations like everything else, so `clear` cannot
228
+ // reach a key the vendor does not have.
229
+ for( const key of ( Array.isArray( clear ) ? clear : [] ) ){
230
+
231
+ if( fields.some( ( field ) => field.key === key ) ) delete merged[ key ];
232
+
233
+ }
234
+
235
+ const result = await controller.update({
236
+ authenticated,
237
+ collection : 'provider',
238
+ data : {
239
+ $set : {
240
+ settings : encrypt( merged )
241
+ }
242
+ },
243
+ options : {
244
+ upsert : true
245
+ },
246
+ query : { slug }
247
+ });
248
+
249
+ // THE PROCESS THAT TOOK THE SAVE IS CORRECT IMMEDIATELY. Without this the
250
+ // admin who just typed the credential in reads back the list this same
251
+ // process memoized a moment ago and sees the vendor still not live, for up to
252
+ // a minute, with nothing to do about it.
253
+ //
254
+ // The other processes are not covered and do not need to be: their memos age
255
+ // out on their own within the window, which is the trade a per-process memo
256
+ // buys in exchange for never needing a bust to travel between services.
257
+ providerMemo.delete( slug );
258
+
259
+ return result;
260
+
261
+ };
262
+
263
+ // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
264
+ //
265
+ // The boundary between "an admin types this in" and "the deployment supplies
266
+ // it". ENCRYPT_CONNECTION_SECRET is the clearest case of the latter: the webhook
267
+ // connection requires it, and it can never be stored here because it is the key
268
+ // that decrypts this collection. Names outside this set keep coming from the
269
+ // environment.
270
+ const providerEnvNames = () => new Set(
271
+ providerSlugs()
272
+ .flatMap( ( slug ) => providerFields( slug ) )
273
+ .map( ( field ) => field.env )
274
+ .filter( Boolean )
275
+ );
276
+
277
+ // EVERY STORED CREDENTIAL, SHAPED LIKE AN ENVIRONMENT.
278
+ //
279
+ // Keyed by the env var each field replaces, because that is the shape the
280
+ // manifests already speak: `requires` names env vars, and auth.oauth.client
281
+ // names them too. Handing availableConnections this map instead of process.env
282
+ // is the whole switch — the package does not change, and 137 call sites in this
283
+ // repo keep reading a plain synchronous object.
284
+ //
285
+ // A field with no value is OMITTED rather than set empty, so `requires` sees
286
+ // the same absence it would for an unset variable.
287
+ const providerCredentials = async ({ controller }) => {
288
+
289
+ const credentials = {};
290
+
291
+ for( const slug of providerSlugs() ){
292
+
293
+ // ONE BAD ROW COSTS ONE VENDOR. decrypt throws on a value that was written
294
+ // under a different ENCRYPT_CONNECTION_SECRET, and unguarded that throw
295
+ // escaped the whole loop — a single half-rotated row read as a platform
296
+ // with no SendGrid, no Twilio and no Shopify either, which is a far worse
297
+ // outage than the one it came from.
298
+ //
299
+ // Degrading to "that vendor is unconfigured" lands in a state the product
300
+ // already has an answer for: the admin screen renders the row as not live,
301
+ // which is exactly where someone goes to fix it.
302
+ try {
303
+
304
+ const settings = await providerSettings({ controller, slug });
305
+
306
+ for( const field of providerFields( slug ) ){
307
+
308
+ const value = settings?.[ field.key ];
309
+
310
+ if( field.env && value ) credentials[ field.env ] = value;
311
+
312
+ }
313
+
314
+ } catch {
315
+
316
+ continue;
317
+
318
+ }
319
+
320
+ }
321
+
322
+ return credentials;
323
+
324
+ };
325
+
326
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };