@drawbridge/drawbridge-utils 0.0.118 → 0.0.124

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,9 @@
1
- import { mergeSettings } from './connections/index.js';
1
+ import { connections, mergeSettings } from './connections/index.js';
2
2
  import { decrypt, encrypt } from './encrypt.js';
3
3
  import './connections/oauth.js';
4
4
  import 'node:crypto';
5
+ import './phone.js';
6
+ import 'libphonenumber-js';
5
7
  import './http.js';
6
8
  import './pricing.js';
7
9
  import './plans.js';
@@ -15,6 +17,7 @@ import './usage.js';
15
17
  import './billing.js';
16
18
  import './transactions.js';
17
19
  import '@drawbridge/drawbridge-telemetry';
20
+ import './email.js';
18
21
  import './safe-http.js';
19
22
  import 'dns';
20
23
  import 'node:http';
@@ -39,60 +42,41 @@ import './token.js';
39
42
  // the others have already rotated away.
40
43
 
41
44
 
42
- // WHAT AN ADMIN TYPES IN, per vendor.
45
+ // WHAT AN ADMIN TYPES IN, per vendor — READ FROM THE MANIFESTS.
43
46
  //
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
+ // This was a table here, duplicating what every vendor file already knew. A
48
+ // vendor's credentials and its `requires` name the same things, and keeping
49
+ // them in two files meant a manifest could require a name that no admin screen
50
+ // offered to fill — the exact shape of "declared in one place, entered in
51
+ // another" these manifests exist to close.
47
52
  //
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
53
+ // Each field's `credential` is the NAME the rest of the system asks for that
54
+ // value by. It is not an environment variable and there is no fallback to one:
55
+ // the names simply began life as env vars and were kept as the vocabulary, so
56
+ // `requires` and auth.oauth.client did not have to change when the storage
57
+ // moved into the database.
58
+ //
59
+ // So the declaration lives on the manifest as `provider : { fields }`, build()
60
+ // checks its shape at import, and this module is now only the reading, memoizing
61
+ // and encrypting around it.
62
+ //
63
+ // Object.hasOwn, not `connections[ slug ] ||` a bare property read walks the
64
+ // prototype chain, and connections[ 'constructor' ] is a function whose .length
65
+ // is 1, so the route's "does this vendor exist" guard passed for it and reached
91
66
  // the database before throwing.
92
- const providerFields = ( slug ) => Object.hasOwn( FIELDS, slug ) ? FIELDS[ slug ] : [];
67
+ const providerFields = ( slug ) => (
68
+ Object.hasOwn( connections, slug ) ? ( connections[ slug ].provider?.fields || [] ) : []
69
+ );
93
70
 
94
71
  // The vendors an admin can configure, in a stable order.
95
- const providerSlugs = () => Object.keys( FIELDS ).sort();
72
+ //
73
+ // DERIVED FROM WHO DECLARES A PROVIDER BLOCK, not from the catalog: `webhook` is
74
+ // a connection with no vendor behind it — nothing to authenticate against and
75
+ // nothing to type — so it must not appear on a screen whose whole purpose is
76
+ // entering credentials.
77
+ const providerSlugs = () => Object.keys( connections )
78
+ .filter( ( slug ) => connections[ slug ].provider )
79
+ .sort();
96
80
 
97
81
  // LIVE IS DERIVED, never stored. A stored flag is a second thing to keep in
98
82
  // sync and goes stale the moment a row is edited by hand.
@@ -120,42 +104,68 @@ const isLive = ( slug, settings ) => {
120
104
  // Fixed-width dots rather than one per character, so the length does not travel
121
105
  // either. Below the threshold a secret is short enough that a prefix and suffix
122
106
  // would be most of it, so nothing is revealed.
107
+ //
108
+ // SIXTEEN, not twelve. The reveal is 3 + 4 = 7 characters, so a twelve-character
109
+ // value handed back more than half of itself while passing a check whose whole
110
+ // point was that it did not — the threshold has to be comfortably more than
111
+ // twice what is shown for the reasoning above to hold.
123
112
  const mask = ( value ) => {
124
113
 
125
114
  if( ! value ) return null;
126
115
 
127
- if( String( value ).length < 12 ) return '••••••••';
116
+ if( String( value ).length < 16 ) return '••••••••';
128
117
 
129
118
  return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
130
119
 
131
120
  };
132
121
 
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
122
+ // A MEMO, NOT A CACHE, and the difference is the whole point. Shared Redis made
123
+ // one process's view of a credential another process's problem: a bust from the
124
+ // api had to reach sync and webhooks before either served a key someone had just
125
+ // rotated because it leaked, and cache.use has no compare-and-set to make that
126
+ // safe. Per-process state cannot be stale for anyone but the process holding it,
127
+ // and it expires on its own.
128
+ //
129
+ // slug -> { at, value }, where value is the DECRYPTED settings. Exported only so
130
+ // tests can age an entry without sleeping; nothing else should read it.
131
+ const providerMemo = new Map();
132
+
133
+ // SIXTY SECONDS, chosen for the human rather than for the load. The read is one
134
+ // indexed document and a decrypt — near enough free that memoizing it longer
135
+ // buys nothing worth the staleness. The moment that actually matters is the one
136
+ // right after an admin first types a credential in and goes looking for the
137
+ // vendor to come alive: they are watching, and a minute is the longest that wait
138
+ // should ever be.
139
+ const MEMO_TTL_MS = 60 * 1000;
140
+
141
+ // For tests. A save clears its own slug; this clears everything.
142
+ const clearProviderMemo = () => providerMemo.clear();
143
+
144
+ // Read a vendor's credentials, memoized, because this is on the path of every
140
145
  // OAuth callback and every send.
141
146
  //
142
147
  // An unconfigured vendor answers {} rather than throwing: the admin list has to
143
148
  // render the row that lets someone fix it.
144
149
  //
145
- // Thirty seconds rather than the default, so a credential rotated away stops
146
- // being served quickly.
147
- const providerSettings = async ({ cache, controller, slug }) => {
150
+ // The memoized object is handed to every reader inside the window rather than
151
+ // copied per read — no caller mutates its credentials, and defending against one
152
+ // that does not exist would cost a clone on every send.
153
+ const providerSettings = async ({ controller, slug }) => {
148
154
 
149
- const read = async () => controller.get({
155
+ const memoized = providerMemo.get( slug );
156
+
157
+ if( memoized && Date.now() - memoized.at < MEMO_TTL_MS ) return memoized.value;
158
+
159
+ const row = await controller.get({
150
160
  collection : 'provider',
151
161
  query : { slug }
152
162
  });
153
163
 
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();
164
+ const value = row?.settings ? decrypt( row.settings ) : {};
157
165
 
158
- return row?.settings ? decrypt( row.settings ) : {};
166
+ providerMemo.set( slug, { at : Date.now(), value });
167
+
168
+ return value;
159
169
 
160
170
  };
161
171
 
@@ -168,7 +178,7 @@ const providerSettings = async ({ cache, controller, slug }) => {
168
178
  // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
169
179
  // unchanged form posts it back empty; treating that as a clear would wipe a
170
180
  // credential every time someone edited the field next to it.
171
- const saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
181
+ const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
172
182
 
173
183
  const fields = providerFields( slug );
174
184
 
@@ -217,33 +227,20 @@ const saveProviderSettings = async ({ authenticated, cache, clear, controller, s
217
227
  query : { slug }
218
228
  });
219
229
 
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.
230
+ // THE PROCESS THAT TOOK THE SAVE IS CORRECT IMMEDIATELY. Without this the
231
+ // admin who just typed the credential in reads back the list this same
232
+ // process memoized a moment ago and sees the vendor still not live, for up to
233
+ // a minute, with nothing to do about it.
223
234
  //
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 ) );
235
+ // The other processes are not covered and do not need to be: their memos age
236
+ // out on their own within the window, which is the trade a per-process memo
237
+ // buys in exchange for never needing a bust to travel between services.
238
+ providerMemo.delete( slug );
232
239
 
233
240
  return result;
234
241
 
235
242
  };
236
243
 
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
244
  // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
248
245
  //
249
246
  // The boundary between "an admin types this in" and "the deployment supplies
@@ -254,64 +251,56 @@ const saveProviderSettings = async ({ authenticated, cache, clear, controller, s
254
251
  const providerEnvNames = () => new Set(
255
252
  providerSlugs()
256
253
  .flatMap( ( slug ) => providerFields( slug ) )
257
- .map( ( field ) => field.env )
254
+ .map( ( field ) => field.credential )
258
255
  .filter( Boolean )
259
256
  );
260
257
 
261
- const providerCredentials = async ({ cache, controller }) => {
258
+ // EVERY STORED CREDENTIAL, KEYED BY ITS NAME.
259
+ //
260
+ // Keyed by each field's `credential`, because that is the vocabulary the
261
+ // manifests already speak: `requires` names credentials, and auth.oauth.client
262
+ // names them too. Handing availableConnections this map is what makes stored
263
+ // credentials answer the question the manifests were already asking.
264
+ //
265
+ // A field with no value is OMITTED rather than set empty, so `requires` sees
266
+ // the same absence it would for an unset variable.
267
+ const providerCredentials = async ({ controller }) => {
262
268
 
263
269
  const credentials = {};
264
270
 
265
271
  for( const slug of providerSlugs() ){
266
272
 
267
- const settings = await providerSettings({ cache, controller, slug });
273
+ // ONE BAD ROW COSTS ONE VENDOR. decrypt throws on a value that was written
274
+ // under a different ENCRYPT_CONNECTION_SECRET, and unguarded that throw
275
+ // escaped the whole loop — a single half-rotated row read as a platform
276
+ // with no SendGrid, no Twilio and no Shopify either, which is a far worse
277
+ // outage than the one it came from.
278
+ //
279
+ // Degrading to "that vendor is unconfigured" lands in a state the product
280
+ // already has an answer for: the admin screen renders the row as not live,
281
+ // which is exactly where someone goes to fix it.
282
+ try {
268
283
 
269
- for( const field of providerFields( slug ) ){
284
+ const settings = await providerSettings({ controller, slug });
270
285
 
271
- const value = settings?.[ field.key ];
286
+ for( const field of providerFields( slug ) ){
272
287
 
273
- if( field.env && value ) credentials[ field.env ] = value;
288
+ const value = settings?.[ field.key ];
274
289
 
275
- }
290
+ if( field.credential && value ) credentials[ field.credential ] = value;
276
291
 
277
- }
278
-
279
- return credentials;
280
-
281
- };
292
+ }
282
293
 
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 }) => {
294
+ } catch {
306
295
 
307
- const credentials = await providerCredentials({ cache, controller });
296
+ continue;
308
297
 
309
- for( const name of providerEnvNames() ) delete env[ name ];
298
+ }
310
299
 
311
- Object.assign( env, credentials );
300
+ }
312
301
 
313
- return Object.keys( credentials ).sort();
302
+ return credentials;
314
303
 
315
304
  };
316
305
 
317
- export { cacheKey, hydrateEnvironment, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerSettings, providerSlugs, saveProviderSettings };
306
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };