@drawbridge/drawbridge-utils 0.0.118 → 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.
@@ -15,6 +15,9 @@ import './usage.cjs';
15
15
  import './billing.cjs';
16
16
  import './transactions.cjs';
17
17
  import '@drawbridge/drawbridge-telemetry';
18
+ import './email.cjs';
19
+ import './phone.cjs';
20
+ import 'libphonenumber-js';
18
21
  import './safe-http.cjs';
19
22
  import 'dns';
20
23
  import 'node:http';
@@ -120,42 +123,68 @@ const isLive = ( slug, settings ) => {
120
123
  // Fixed-width dots rather than one per character, so the length does not travel
121
124
  // either. Below the threshold a secret is short enough that a prefix and suffix
122
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.
123
131
  const mask = ( value ) => {
124
132
 
125
133
  if( ! value ) return null;
126
134
 
127
- if( String( value ).length < 12 ) return '••••••••';
135
+ if( String( value ).length < 16 ) return '••••••••';
128
136
 
129
137
  return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
130
138
 
131
139
  };
132
140
 
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
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
140
164
  // OAuth callback and every send.
141
165
  //
142
166
  // An unconfigured vendor answers {} rather than throwing: the admin list has to
143
167
  // render the row that lets someone fix it.
144
168
  //
145
- // Thirty seconds rather than the default, so a credential rotated away stops
146
- // being served quickly.
147
- const providerSettings = async ({ cache, controller, slug }) => {
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;
148
177
 
149
- const read = async () => controller.get({
178
+ const row = await controller.get({
150
179
  collection : 'provider',
151
180
  query : { slug }
152
181
  });
153
182
 
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();
183
+ const value = row?.settings ? decrypt( row.settings ) : {};
157
184
 
158
- return row?.settings ? decrypt( row.settings ) : {};
185
+ providerMemo.set( slug, { at : Date.now(), value });
186
+
187
+ return value;
159
188
 
160
189
  };
161
190
 
@@ -168,7 +197,7 @@ const providerSettings = async ({ cache, controller, slug }) => {
168
197
  // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
169
198
  // unchanged form posts it back empty; treating that as a clear would wipe a
170
199
  // credential every time someone edited the field next to it.
171
- const saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
200
+ const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
172
201
 
173
202
  const fields = providerFields( slug );
174
203
 
@@ -217,33 +246,20 @@ const saveProviderSettings = async ({ authenticated, cache, clear, controller, s
217
246
  query : { slug }
218
247
  });
219
248
 
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.
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.
223
253
  //
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 ) );
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 );
232
258
 
233
259
  return result;
234
260
 
235
261
  };
236
262
 
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
263
  // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
248
264
  //
249
265
  // The boundary between "an admin types this in" and "the deployment supplies
@@ -258,60 +274,53 @@ const providerEnvNames = () => new Set(
258
274
  .filter( Boolean )
259
275
  );
260
276
 
261
- const providerCredentials = async ({ cache, controller }) => {
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 }) => {
262
288
 
263
289
  const credentials = {};
264
290
 
265
291
  for( const slug of providerSlugs() ){
266
292
 
267
- const settings = await providerSettings({ cache, controller, slug });
268
-
269
- for( const field of providerFields( slug ) ){
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 {
270
303
 
271
- const value = settings?.[ field.key ];
304
+ const settings = await providerSettings({ controller, slug });
272
305
 
273
- if( field.env && value ) credentials[ field.env ] = value;
306
+ for( const field of providerFields( slug ) ){
274
307
 
275
- }
276
-
277
- }
308
+ const value = settings?.[ field.key ];
278
309
 
279
- return credentials;
310
+ if( field.env && value ) credentials[ field.env ] = value;
280
311
 
281
- };
312
+ }
282
313
 
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 }) => {
314
+ } catch {
306
315
 
307
- const credentials = await providerCredentials({ cache, controller });
316
+ continue;
308
317
 
309
- for( const name of providerEnvNames() ) delete env[ name ];
318
+ }
310
319
 
311
- Object.assign( env, credentials );
320
+ }
312
321
 
313
- return Object.keys( credentials ).sort();
322
+ return credentials;
314
323
 
315
324
  };
316
325
 
317
- export { cacheKey, hydrateEnvironment, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerSettings, providerSlugs, saveProviderSettings };
326
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };
@@ -15,6 +15,9 @@ import './usage.js';
15
15
  import './billing.js';
16
16
  import './transactions.js';
17
17
  import '@drawbridge/drawbridge-telemetry';
18
+ import './email.js';
19
+ import './phone.js';
20
+ import 'libphonenumber-js';
18
21
  import './safe-http.js';
19
22
  import 'dns';
20
23
  import 'node:http';
@@ -120,42 +123,68 @@ const isLive = ( slug, settings ) => {
120
123
  // Fixed-width dots rather than one per character, so the length does not travel
121
124
  // either. Below the threshold a secret is short enough that a prefix and suffix
122
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.
123
131
  const mask = ( value ) => {
124
132
 
125
133
  if( ! value ) return null;
126
134
 
127
- if( String( value ).length < 12 ) return '••••••••';
135
+ if( String( value ).length < 16 ) return '••••••••';
128
136
 
129
137
  return String( value ).slice( 0, 3 ) + '••••••••' + String( value ).slice( -4 );
130
138
 
131
139
  };
132
140
 
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
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
140
164
  // OAuth callback and every send.
141
165
  //
142
166
  // An unconfigured vendor answers {} rather than throwing: the admin list has to
143
167
  // render the row that lets someone fix it.
144
168
  //
145
- // Thirty seconds rather than the default, so a credential rotated away stops
146
- // being served quickly.
147
- const providerSettings = async ({ cache, controller, slug }) => {
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;
148
177
 
149
- const read = async () => controller.get({
178
+ const row = await controller.get({
150
179
  collection : 'provider',
151
180
  query : { slug }
152
181
  });
153
182
 
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();
183
+ const value = row?.settings ? decrypt( row.settings ) : {};
157
184
 
158
- return row?.settings ? decrypt( row.settings ) : {};
185
+ providerMemo.set( slug, { at : Date.now(), value });
186
+
187
+ return value;
159
188
 
160
189
  };
161
190
 
@@ -168,7 +197,7 @@ const providerSettings = async ({ cache, controller, slug }) => {
168
197
  // BLANK MEANS KEEP. A redacted field is never returned by the GET, so an
169
198
  // unchanged form posts it back empty; treating that as a clear would wipe a
170
199
  // credential every time someone edited the field next to it.
171
- const saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
200
+ const saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
172
201
 
173
202
  const fields = providerFields( slug );
174
203
 
@@ -217,33 +246,20 @@ const saveProviderSettings = async ({ authenticated, cache, clear, controller, s
217
246
  query : { slug }
218
247
  });
219
248
 
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.
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.
223
253
  //
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 ) );
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 );
232
258
 
233
259
  return result;
234
260
 
235
261
  };
236
262
 
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
263
  // EVERY ENV NAME THE PROVIDER SCREEN OWNS.
248
264
  //
249
265
  // The boundary between "an admin types this in" and "the deployment supplies
@@ -258,60 +274,53 @@ const providerEnvNames = () => new Set(
258
274
  .filter( Boolean )
259
275
  );
260
276
 
261
- const providerCredentials = async ({ cache, controller }) => {
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 }) => {
262
288
 
263
289
  const credentials = {};
264
290
 
265
291
  for( const slug of providerSlugs() ){
266
292
 
267
- const settings = await providerSettings({ cache, controller, slug });
268
-
269
- for( const field of providerFields( slug ) ){
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 {
270
303
 
271
- const value = settings?.[ field.key ];
304
+ const settings = await providerSettings({ controller, slug });
272
305
 
273
- if( field.env && value ) credentials[ field.env ] = value;
306
+ for( const field of providerFields( slug ) ){
274
307
 
275
- }
276
-
277
- }
308
+ const value = settings?.[ field.key ];
278
309
 
279
- return credentials;
310
+ if( field.env && value ) credentials[ field.env ] = value;
280
311
 
281
- };
312
+ }
282
313
 
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 }) => {
314
+ } catch {
306
315
 
307
- const credentials = await providerCredentials({ cache, controller });
316
+ continue;
308
317
 
309
- for( const name of providerEnvNames() ) delete env[ name ];
318
+ }
310
319
 
311
- Object.assign( env, credentials );
320
+ }
312
321
 
313
- return Object.keys( credentials ).sort();
322
+ return credentials;
314
323
 
315
324
  };
316
325
 
317
- export { cacheKey, hydrateEnvironment, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerSettings, providerSlugs, saveProviderSettings };
326
+ export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };