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