@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.
- package/dist/connections/index.cjs +1610 -183
- package/dist/connections/index.d.cts +2480 -275
- package/dist/connections/index.d.ts +2480 -275
- package/dist/connections/index.js +1601 -179
- package/dist/phone.d.cts +1 -1
- package/dist/phone.d.ts +1 -1
- package/dist/providers.cjs +1587 -240
- package/dist/providers.d.cts +117 -128
- package/dist/providers.d.ts +117 -128
- package/dist/providers.js +1581 -234
- package/dist/sendgrid.cjs +4 -6
- package/dist/sendgrid.d.cts +19 -14
- package/dist/sendgrid.d.ts +19 -14
- package/dist/sendgrid.js +4 -6
- package/dist/twilio.cjs +2 -3
- package/dist/twilio.d.cts +11 -3
- package/dist/twilio.d.ts +11 -3
- package/dist/twilio.js +2 -3
- package/package.json +1 -1
package/dist/providers.d.cts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { mergeSettings } from './connections/index.cjs';
|
|
1
|
+
import { connections, mergeSettings } from './connections/index.cjs';
|
|
2
2
|
import { decrypt, encrypt } from './encrypt.cjs';
|
|
3
3
|
import './connections/oauth.cjs';
|
|
4
4
|
import 'node:crypto';
|
|
5
|
+
import './phone.cjs';
|
|
6
|
+
import 'libphonenumber-js';
|
|
5
7
|
import './http.cjs';
|
|
6
8
|
import './pricing.cjs';
|
|
7
9
|
import './plans.cjs';
|
|
@@ -15,6 +17,7 @@ import './usage.cjs';
|
|
|
15
17
|
import './billing.cjs';
|
|
16
18
|
import './transactions.cjs';
|
|
17
19
|
import '@drawbridge/drawbridge-telemetry';
|
|
20
|
+
import './email.cjs';
|
|
18
21
|
import './safe-http.cjs';
|
|
19
22
|
import 'dns';
|
|
20
23
|
import 'node:http';
|
|
@@ -39,60 +42,41 @@ import './token.cjs';
|
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 ) =>
|
|
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
|
-
|
|
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 <
|
|
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
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
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
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// the
|
|
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
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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.
|
|
254
|
+
.map( ( field ) => field.credential )
|
|
258
255
|
.filter( Boolean )
|
|
259
256
|
);
|
|
260
257
|
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
+
const settings = await providerSettings({ controller, slug });
|
|
270
285
|
|
|
271
|
-
const
|
|
286
|
+
for( const field of providerFields( slug ) ){
|
|
272
287
|
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
continue;
|
|
308
297
|
|
|
309
|
-
|
|
298
|
+
}
|
|
310
299
|
|
|
311
|
-
|
|
300
|
+
}
|
|
312
301
|
|
|
313
|
-
return
|
|
302
|
+
return credentials;
|
|
314
303
|
|
|
315
304
|
};
|
|
316
305
|
|
|
317
|
-
export {
|
|
306
|
+
export { clearProviderMemo, isLive, mask, providerCredentials, providerEnvNames, providerFields, providerMemo, providerSettings, providerSlugs, saveProviderSettings };
|