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