@global-torque/invest-core 0.2.2
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/NOTICE.md +11 -0
- package/README.md +76 -0
- package/SECURITY.md +9 -0
- package/SUPPORT.md +6 -0
- package/dist/node/app/config.js +173 -0
- package/dist/node/helpers/text.js +37 -0
- package/dist/node/markdown/tableWrap.js +23 -0
- package/package.json +113 -0
- package/src/accreditation/status.ts +100 -0
- package/src/analytics/__tests__/analyticsBody.test.ts +50 -0
- package/src/analytics/analyticsBody.ts +270 -0
- package/src/app/config.test.ts +79 -0
- package/src/app/config.ts +329 -0
- package/src/decimal/__tests__/canonicalDecimal.test.ts +58 -0
- package/src/decimal/canonicalDecimal.ts +154 -0
- package/src/evm/__tests__/walletInfo.test.ts +488 -0
- package/src/evm/walletInfo.ts +625 -0
- package/src/filer/__tests__/documentFormatter.test.ts +208 -0
- package/src/filer/__tests__/publicImage.test.ts +42 -0
- package/src/filer/documentFormatter.ts +195 -0
- package/src/filer/publicImage.ts +120 -0
- package/src/form-validation/__tests__/general.test.ts +78 -0
- package/src/form-validation/__tests__/investment.test.ts +100 -0
- package/src/form-validation/ajv.ts +109 -0
- package/src/form-validation/constants.ts +22 -0
- package/src/form-validation/general.ts +114 -0
- package/src/form-validation/index.ts +5 -0
- package/src/form-validation/investment.ts +65 -0
- package/src/form-validation/rules.ts +35 -0
- package/src/formatting/__tests__/buildInfo.test.ts +19 -0
- package/src/formatting/__tests__/dateTime.test.ts +24 -0
- package/src/formatting/__tests__/display.test.ts +30 -0
- package/src/formatting/buildInfo.ts +31 -0
- package/src/formatting/dateTime.ts +43 -0
- package/src/formatting/display.ts +24 -0
- package/src/helpers/arrays.ts +11 -0
- package/src/helpers/currency.ts +19 -0
- package/src/helpers/formatters/formatToDate.ts +47 -0
- package/src/helpers/formatters/formatToNumber.ts +39 -0
- package/src/helpers/formatters/formatToPhone.ts +13 -0
- package/src/helpers/general.ts +164 -0
- package/src/helpers/model.ts +87 -0
- package/src/helpers/numberFormatter.ts +4 -0
- package/src/helpers/text.ts +51 -0
- package/src/index.ts +22 -0
- package/src/investment/__tests__/status.test.ts +59 -0
- package/src/investment/rawAmount.test.ts +23 -0
- package/src/investment/rawAmount.ts +81 -0
- package/src/investment/status.ts +56 -0
- package/src/kyc/__tests__/kycAlert.formatter.test.ts +47 -0
- package/src/kyc/__tests__/kycAlert.test.ts +47 -0
- package/src/kyc/__tests__/thirdPartyScreen.test.ts +16 -0
- package/src/kyc/kycAlert.ts +47 -0
- package/src/kyc/status.ts +109 -0
- package/src/kyc/thirdPartyScreen.ts +28 -0
- package/src/markdown/tableWrap.ts +29 -0
- package/src/notifications/shareFields.ts +15 -0
- package/src/offer/__tests__/metrics.test.ts +67 -0
- package/src/offer/formatter.ts +559 -0
- package/src/offer/metrics.ts +83 -0
- package/src/onboarding/__tests__/intents.test.ts +83 -0
- package/src/onboarding/intents.ts +128 -0
- package/src/profiles/__tests__/formatting.test.ts +24 -0
- package/src/profiles/avatarInitial.ts +5 -0
- package/src/profiles/formatting.ts +38 -0
- package/src/repository/__tests__/formatterCache.test.ts +45 -0
- package/src/repository/formatterCache.ts +56 -0
- package/src/wallet/__tests__/auth.test.ts +102 -0
- package/src/wallet/__tests__/operationPresentation.test.ts +94 -0
- package/src/wallet/__tests__/setupError.test.ts +32 -0
- package/src/wallet/auth.ts +491 -0
- package/src/wallet/operationPresentation.ts +106 -0
- package/src/wallet/setupError.ts +50 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { AnalyticsBody } from '@global-torque/domain-types/analyticsTypes';
|
|
2
|
+
|
|
3
|
+
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
4
|
+
const REDACTED_VALUE = '[redacted]';
|
|
5
|
+
const REDACTED_BODY: AnalyticsBody = { redacted: true };
|
|
6
|
+
const SENSITIVE_EXACT_KEYS = new Set([
|
|
7
|
+
'address',
|
|
8
|
+
'address1',
|
|
9
|
+
'address2',
|
|
10
|
+
'city',
|
|
11
|
+
'country',
|
|
12
|
+
'account_holder_name',
|
|
13
|
+
'code',
|
|
14
|
+
'dob',
|
|
15
|
+
'email',
|
|
16
|
+
'first_name',
|
|
17
|
+
'full_name',
|
|
18
|
+
'ip_address',
|
|
19
|
+
'last_name',
|
|
20
|
+
'middle_name',
|
|
21
|
+
'postal_code',
|
|
22
|
+
'state',
|
|
23
|
+
'wallet',
|
|
24
|
+
'user_browser',
|
|
25
|
+
'zip',
|
|
26
|
+
'zip_code',
|
|
27
|
+
]);
|
|
28
|
+
const MAX_STRING_LENGTH = 500;
|
|
29
|
+
const SENSITIVE_KEY_SUBSTRINGS = [
|
|
30
|
+
'account_number',
|
|
31
|
+
'csrf',
|
|
32
|
+
'passcode',
|
|
33
|
+
'password',
|
|
34
|
+
'routing_number',
|
|
35
|
+
'secret',
|
|
36
|
+
'social_security',
|
|
37
|
+
'ssn',
|
|
38
|
+
'tax_id',
|
|
39
|
+
'totp',
|
|
40
|
+
] as const;
|
|
41
|
+
const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
42
|
+
const LONG_DIGIT_PATTERN = /\d{10,}/g;
|
|
43
|
+
const JWT_LIKE_PATTERN = /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/g;
|
|
44
|
+
const BEARER_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
45
|
+
const SECRET_ASSIGNMENT_PATTERN = /\b(access[_-]?token|refresh[_-]?token|id[_-]?token|auth[_-]?token|csrf[_-]?token|session[_-]?token|token|secret|client[_-]?secret|api[_-]?key|authorization|auth[_-]?code|verification[_-]?code|recovery[_-]?code|password|passcode|otp|totp|pin)\b\s*[:=]\s*("[^"]*"|'[^']*'|(?:Bearer|Basic)\s+[^\s,;&]+|[^\s,;&]+)/gi;
|
|
46
|
+
|
|
47
|
+
export const sanitizeAnalyticsText = (
|
|
48
|
+
raw: unknown,
|
|
49
|
+
maxLength = MAX_STRING_LENGTH,
|
|
50
|
+
): string => {
|
|
51
|
+
if (raw == null) {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let sanitized = String(raw)
|
|
56
|
+
.replace(SECRET_ASSIGNMENT_PATTERN, '$1=[redacted]')
|
|
57
|
+
.replace(BEARER_TOKEN_PATTERN, '$1 [redacted]')
|
|
58
|
+
.replace(JWT_LIKE_PATTERN, REDACTED_VALUE)
|
|
59
|
+
.replace(EMAIL_PATTERN, REDACTED_VALUE)
|
|
60
|
+
.replace(LONG_DIGIT_PATTERN, REDACTED_VALUE);
|
|
61
|
+
|
|
62
|
+
if (sanitized.length > maxLength) {
|
|
63
|
+
sanitized = `${sanitized.slice(0, maxLength - 3)}...`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return sanitized;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const isSensitiveKey = (key: string): boolean => {
|
|
70
|
+
const normalized = key.trim().toLowerCase();
|
|
71
|
+
|
|
72
|
+
if (!normalized) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (SENSITIVE_EXACT_KEYS.has(normalized)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (SENSITIVE_KEY_SUBSTRINGS.some((fragment) => normalized.includes(fragment))) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
normalized === 'token'
|
|
86
|
+
|| normalized.endsWith('token')
|
|
87
|
+
|| normalized.startsWith('token')
|
|
88
|
+
|| normalized.endsWith('_token')
|
|
89
|
+
|| normalized.startsWith('token_')
|
|
90
|
+
) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
normalized === 'otp'
|
|
96
|
+
|| normalized === 'pin'
|
|
97
|
+
|| normalized.endsWith('_otp')
|
|
98
|
+
|| normalized.endsWith('_pin')
|
|
99
|
+
|| normalized.endsWith('otp')
|
|
100
|
+
|| normalized.endsWith('pin')
|
|
101
|
+
|| (
|
|
102
|
+
(normalized.endsWith('_code') || normalized.endsWith('code'))
|
|
103
|
+
&& (
|
|
104
|
+
normalized.includes('otp')
|
|
105
|
+
|| normalized.includes('totp')
|
|
106
|
+
|| normalized.includes('pin')
|
|
107
|
+
|| normalized.includes('verification')
|
|
108
|
+
|| normalized.includes('recovery')
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const isBlobSupported = () => typeof Blob !== 'undefined';
|
|
115
|
+
const isFormDataSupported = () => typeof FormData !== 'undefined';
|
|
116
|
+
const isFileSupported = () => typeof File !== 'undefined';
|
|
117
|
+
|
|
118
|
+
const normalizeBinaryValue = (value: Blob): string => {
|
|
119
|
+
if (isFileSupported() && value instanceof File && value.name.trim()) {
|
|
120
|
+
return '[binary]';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return '[binary]';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const normalizeObjectValue = (
|
|
127
|
+
value: Record<string, unknown>,
|
|
128
|
+
seen: WeakSet<object>,
|
|
129
|
+
): AnalyticsBody => {
|
|
130
|
+
seen.add(value);
|
|
131
|
+
|
|
132
|
+
const normalized = Object.entries(value).reduce<AnalyticsBody>((result, [key, item]) => {
|
|
133
|
+
result[key] = isSensitiveKey(key)
|
|
134
|
+
? REDACTED_VALUE
|
|
135
|
+
: normalizeBodyValue(item, seen);
|
|
136
|
+
return result;
|
|
137
|
+
}, {});
|
|
138
|
+
|
|
139
|
+
seen.delete(value);
|
|
140
|
+
return normalized;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const appendFormDataEntry = (
|
|
144
|
+
target: AnalyticsBody,
|
|
145
|
+
key: string,
|
|
146
|
+
value: unknown,
|
|
147
|
+
) => {
|
|
148
|
+
const currentValue = target[key];
|
|
149
|
+
|
|
150
|
+
if (typeof currentValue === 'undefined') {
|
|
151
|
+
target[key] = value;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
target[key] = Array.isArray(currentValue)
|
|
156
|
+
? [...currentValue, value]
|
|
157
|
+
: [currentValue, value];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const normalizeFormData = (
|
|
161
|
+
formData: FormData,
|
|
162
|
+
seen: WeakSet<object>,
|
|
163
|
+
): AnalyticsBody => {
|
|
164
|
+
const normalized: AnalyticsBody = {};
|
|
165
|
+
|
|
166
|
+
for (const [key, value] of formData.entries()) {
|
|
167
|
+
appendFormDataEntry(
|
|
168
|
+
normalized,
|
|
169
|
+
key,
|
|
170
|
+
isSensitiveKey(key) ? REDACTED_VALUE : normalizeBodyValue(value, seen),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return normalized;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const normalizeBodyValue = (
|
|
178
|
+
value: unknown,
|
|
179
|
+
seen: WeakSet<object>,
|
|
180
|
+
): unknown => {
|
|
181
|
+
if (value == null) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (typeof value === 'string') {
|
|
186
|
+
return sanitizeAnalyticsText(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (typeof value === 'boolean') {
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (typeof value === 'number') {
|
|
194
|
+
return Number.isFinite(value) ? value : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeof value === 'bigint') {
|
|
198
|
+
return value.toString();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (value instanceof Date) {
|
|
202
|
+
return value.toISOString();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (isBlobSupported() && value instanceof Blob) {
|
|
206
|
+
return normalizeBinaryValue(value);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
return value.map((item) => normalizeBodyValue(item, seen));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isFormDataSupported() && value instanceof FormData) {
|
|
214
|
+
return normalizeFormData(value, seen);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (typeof value === 'object') {
|
|
218
|
+
if (seen.has(value as object)) {
|
|
219
|
+
return '[circular]';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return normalizeObjectValue(value as Record<string, unknown>, seen);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return String(value);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const normalizeAnalyticsBody = (value: unknown): AnalyticsBody => {
|
|
229
|
+
if (value == null) {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (typeof value === 'string') {
|
|
234
|
+
const trimmedValue = value.trim();
|
|
235
|
+
if (!trimmedValue) {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
return normalizeAnalyticsBody(JSON.parse(trimmedValue));
|
|
241
|
+
} catch {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (isFormDataSupported() && value instanceof FormData) {
|
|
247
|
+
return normalizeFormData(value, new WeakSet<object>());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return normalizeObjectValue(value as Record<string, unknown>, new WeakSet<object>());
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const normalizeAnalyticsBodyForMethod = (
|
|
258
|
+
method: string | undefined,
|
|
259
|
+
value: unknown,
|
|
260
|
+
): AnalyticsBody => {
|
|
261
|
+
if (!method || !MUTATION_METHODS.has(method.toUpperCase())) {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (value == null || (typeof value === 'string' && value.trim() === '')) {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { ...REDACTED_BODY };
|
|
270
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertInvestRuntimeBrandConfig,
|
|
4
|
+
createInvestAppConfigFromEnv,
|
|
5
|
+
serializeStaticConfigForInlineScript,
|
|
6
|
+
type InvestRuntimeBrandConfig,
|
|
7
|
+
} from './config.ts';
|
|
8
|
+
|
|
9
|
+
describe('createInvestAppConfigFromEnv', () => {
|
|
10
|
+
const staticConfig: InvestRuntimeBrandConfig = {
|
|
11
|
+
brand: {
|
|
12
|
+
profile: 'example', title: 'Example Invest', description: 'Example investments.',
|
|
13
|
+
email: 'invest@example.test', logo: '/brand/example.svg',
|
|
14
|
+
logoReversed: '/brand/example-reversed.svg', mark: '/brand/example-mark.svg',
|
|
15
|
+
pwaName: 'Example Investor',
|
|
16
|
+
},
|
|
17
|
+
contact: {
|
|
18
|
+
address1: 'USA', address2: 'Example City', phone: '+1 555 0100', email: 'invest@example.test',
|
|
19
|
+
},
|
|
20
|
+
socials: {
|
|
21
|
+
example: { icon: '/brand/social.svg', iconName: 'example', name: 'Example', href: 'https://social.example.test' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('maps a host-owned environment without reading ambient globals', () => {
|
|
26
|
+
const config = createInvestAppConfigFromEnv({
|
|
27
|
+
ENV: 'stage',
|
|
28
|
+
DEV: 'true',
|
|
29
|
+
IS_STATIC_SITE: '1',
|
|
30
|
+
FRONTEND_URL_DASHBOARD: 'https://dashboard.example.test',
|
|
31
|
+
EVM_URL: 'https://evm.example.test/',
|
|
32
|
+
STABLE_COIN: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD',
|
|
33
|
+
}, staticConfig);
|
|
34
|
+
|
|
35
|
+
expect(config.env).toBe('stage');
|
|
36
|
+
expect(config.isDev).toBe(true);
|
|
37
|
+
expect(config.isStaticSite).toBe(true);
|
|
38
|
+
expect(config.urls.dashboard).toBe('https://dashboard.example.test');
|
|
39
|
+
expect(config.stableCoinAddress).toBe('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd');
|
|
40
|
+
expect(config.brand).toMatchObject({
|
|
41
|
+
profile: 'example',
|
|
42
|
+
title: 'Example Invest',
|
|
43
|
+
logo: '/brand/example.svg',
|
|
44
|
+
pwaName: 'Example Investor',
|
|
45
|
+
});
|
|
46
|
+
expect(config.thirdParty.turnkeyServerSignUrl)
|
|
47
|
+
.toBe('https://evm.example.test/auth/turnkey/server-sign');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects missing and blank required brand values', () => {
|
|
51
|
+
expect(() => assertInvestRuntimeBrandConfig(undefined))
|
|
52
|
+
.toThrow('runtime');
|
|
53
|
+
expect(() => assertInvestRuntimeBrandConfig({
|
|
54
|
+
...staticConfig,
|
|
55
|
+
brand: { ...staticConfig.brand, title: ' ' },
|
|
56
|
+
})).toThrow('runtime.brand.title');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects incomplete runtime identity with precise paths', () => {
|
|
60
|
+
expect(() => assertInvestRuntimeBrandConfig({
|
|
61
|
+
...staticConfig,
|
|
62
|
+
contact: { ...staticConfig.contact, phone: ' ' },
|
|
63
|
+
})).toThrow('runtime.contact.phone');
|
|
64
|
+
expect(() => assertInvestRuntimeBrandConfig({
|
|
65
|
+
...staticConfig,
|
|
66
|
+
socials: { example: { ...staticConfig.socials.example, icon: '' } },
|
|
67
|
+
})).toThrow('runtime.socials.example.icon');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects malformed stable-coin addresses', () => {
|
|
71
|
+
expect(() => createInvestAppConfigFromEnv({ STABLE_COIN: '0x1234' }, staticConfig))
|
|
72
|
+
.toThrow('20-byte');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('serializes inline config without allowing script-tag breakout', () => {
|
|
76
|
+
expect(serializeStaticConfigForInlineScript({ title: '</script><script>alert(1)</script>' }))
|
|
77
|
+
.not.toContain('</script>');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
export type InvestAppApiUrls = {
|
|
2
|
+
kratos?: string;
|
|
3
|
+
plaid?: string;
|
|
4
|
+
investment?: string;
|
|
5
|
+
offer?: string;
|
|
6
|
+
esign?: string;
|
|
7
|
+
user?: string;
|
|
8
|
+
notification?: string;
|
|
9
|
+
accreditation?: string;
|
|
10
|
+
payments?: string;
|
|
11
|
+
wallet?: string;
|
|
12
|
+
evm?: string;
|
|
13
|
+
filer?: string;
|
|
14
|
+
distributions?: string;
|
|
15
|
+
analytic?: string;
|
|
16
|
+
docuseal?: string;
|
|
17
|
+
fundManager?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type InvestAppUrls = {
|
|
21
|
+
frontend?: string;
|
|
22
|
+
dashboard?: string;
|
|
23
|
+
static?: string;
|
|
24
|
+
cryptoWalletScan?: string;
|
|
25
|
+
api: InvestAppApiUrls;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type InvestAppBrandConfig = {
|
|
29
|
+
profile?: string;
|
|
30
|
+
title: string;
|
|
31
|
+
description: string;
|
|
32
|
+
author?: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
logo?: string;
|
|
35
|
+
logoReversed?: string;
|
|
36
|
+
mark?: string;
|
|
37
|
+
favicon?: string;
|
|
38
|
+
socialImage?: string;
|
|
39
|
+
pwaIcon?: string;
|
|
40
|
+
pwaName?: string;
|
|
41
|
+
pwaShortName?: string;
|
|
42
|
+
stylesheet?: string;
|
|
43
|
+
themeColor?: string;
|
|
44
|
+
seoTitle?: string;
|
|
45
|
+
seoDescription?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type RequiredInvestRuntimeBrand = Required<Pick<
|
|
49
|
+
InvestAppBrandConfig,
|
|
50
|
+
| 'profile'
|
|
51
|
+
| 'title'
|
|
52
|
+
| 'description'
|
|
53
|
+
| 'email'
|
|
54
|
+
| 'logo'
|
|
55
|
+
| 'logoReversed'
|
|
56
|
+
| 'mark'
|
|
57
|
+
| 'pwaName'
|
|
58
|
+
>>;
|
|
59
|
+
|
|
60
|
+
export type InvestStaticContactConfig = {
|
|
61
|
+
address1: string;
|
|
62
|
+
address2: string;
|
|
63
|
+
phone: string;
|
|
64
|
+
email: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type InvestStaticSocialConfig = {
|
|
68
|
+
icon: string;
|
|
69
|
+
iconName: string;
|
|
70
|
+
name: string;
|
|
71
|
+
href?: string;
|
|
72
|
+
shareHref?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type InvestRuntimeBrandConfig = {
|
|
76
|
+
brand: RequiredInvestRuntimeBrand;
|
|
77
|
+
contact: InvestStaticContactConfig;
|
|
78
|
+
socials: Readonly<Record<string, InvestStaticSocialConfig>>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type InvestAppDemoAccountConfig = {
|
|
82
|
+
email?: string;
|
|
83
|
+
password?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type InvestAppBuildConfig = {
|
|
87
|
+
version?: string;
|
|
88
|
+
timestamp?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type InvestAppConfig = {
|
|
92
|
+
env?: string;
|
|
93
|
+
isDev?: boolean;
|
|
94
|
+
isStaticSite?: boolean;
|
|
95
|
+
enableAnalytics?: boolean;
|
|
96
|
+
cookieDomain?: string;
|
|
97
|
+
stableCoinAddress?: string;
|
|
98
|
+
pwaTestHostname?: string;
|
|
99
|
+
urls: InvestAppUrls;
|
|
100
|
+
brand: InvestAppBrandConfig;
|
|
101
|
+
demoAccount?: InvestAppDemoAccountConfig;
|
|
102
|
+
build?: InvestAppBuildConfig;
|
|
103
|
+
thirdParty: Record<string, string | undefined>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type InvestAppBaseConfig = Omit<InvestAppConfig, 'brand'>;
|
|
107
|
+
|
|
108
|
+
export type InvestAppLinkConfig = Pick<InvestAppUrls, 'dashboard' | 'static'>;
|
|
109
|
+
|
|
110
|
+
export type InvestAppConfigEnvironment = Partial<Record<string, string | boolean | undefined>>;
|
|
111
|
+
|
|
112
|
+
const REQUIRED_RUNTIME_BRAND_KEYS = [
|
|
113
|
+
'profile', 'title', 'description', 'email', 'logo', 'logoReversed', 'mark', 'pwaName',
|
|
114
|
+
] as const;
|
|
115
|
+
|
|
116
|
+
const assertNonBlank = (value: unknown, path: string): void => {
|
|
117
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
118
|
+
throw new Error(`Static build configuration requires nonblank ${path}.`);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const assertObject = (value: unknown, path: string): Record<string, unknown> => {
|
|
123
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
124
|
+
throw new Error(`Static build configuration requires object ${path}.`);
|
|
125
|
+
}
|
|
126
|
+
return value as Record<string, unknown>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const assertOptionalNonBlank = (value: unknown, path: string): void => {
|
|
130
|
+
if (value !== undefined) assertNonBlank(value, path);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function assertInvestRuntimeBrandConfig(
|
|
134
|
+
config: unknown,
|
|
135
|
+
path = 'runtime',
|
|
136
|
+
): asserts config is InvestRuntimeBrandConfig {
|
|
137
|
+
const runtime = assertObject(config, path);
|
|
138
|
+
const brand = assertObject(runtime.brand, `${path}.brand`);
|
|
139
|
+
for (const key of REQUIRED_RUNTIME_BRAND_KEYS) {
|
|
140
|
+
assertNonBlank(brand[key], `${path}.brand.${key}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const contact = assertObject(runtime.contact, `${path}.contact`);
|
|
144
|
+
for (const key of ['address1', 'address2', 'phone', 'email'] as const) {
|
|
145
|
+
assertNonBlank(contact[key], `${path}.contact.${key}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const socials = assertObject(runtime.socials, `${path}.socials`);
|
|
149
|
+
if (Object.keys(socials).length === 0) {
|
|
150
|
+
throw new Error(`Static build configuration requires at least one ${path}.socials entry.`);
|
|
151
|
+
}
|
|
152
|
+
for (const [key, rawSocial] of Object.entries(socials)) {
|
|
153
|
+
const social = assertObject(rawSocial, `${path}.socials.${key}`);
|
|
154
|
+
for (const field of ['icon', 'iconName', 'name'] as const) {
|
|
155
|
+
assertNonBlank(social[field], `${path}.socials.${key}.${field}`);
|
|
156
|
+
}
|
|
157
|
+
assertOptionalNonBlank(social.href, `${path}.socials.${key}.href`);
|
|
158
|
+
assertOptionalNonBlank(social.shareHref, `${path}.socials.${key}.shareHref`);
|
|
159
|
+
if (social.href === undefined && social.shareHref === undefined) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Static build configuration requires ${path}.socials.${key}.href or ${path}.socials.${key}.shareHref.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function serializeStaticConfigForInlineScript(value: unknown): string {
|
|
168
|
+
return JSON.stringify(value).replace(/</gu, '\\u003c');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const TURNKEY_SERVER_SIGN_PATH = 'auth/turnkey/server-sign';
|
|
172
|
+
const EVM_CONTRACT_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
|
|
173
|
+
|
|
174
|
+
const joinUrlPath = (baseUrl: string, path: string): string => {
|
|
175
|
+
const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, '');
|
|
176
|
+
const trimmedPath = path.trim().replace(/^\/+/, '');
|
|
177
|
+
return trimmedBaseUrl && trimmedPath ? `${trimmedBaseUrl}/${trimmedPath}` : '';
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const normalizeStableCoinAddress = (value: unknown): string => {
|
|
181
|
+
const address = String(value ?? '').trim();
|
|
182
|
+
if (!address) return '';
|
|
183
|
+
if (!EVM_CONTRACT_ADDRESS_PATTERN.test(address)) {
|
|
184
|
+
throw new Error('STABLE_COIN must be a 20-byte 0x-prefixed EVM contract address.');
|
|
185
|
+
}
|
|
186
|
+
return address.toLowerCase();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Converts a host-owned environment record into the immutable application
|
|
191
|
+
* configuration consumed by invest-runtime. Reading build-tool environment
|
|
192
|
+
* globals remains the responsibility of each app.
|
|
193
|
+
*/
|
|
194
|
+
export function createInvestAppConfigFromEnv(
|
|
195
|
+
env: InvestAppConfigEnvironment,
|
|
196
|
+
): InvestAppBaseConfig;
|
|
197
|
+
export function createInvestAppConfigFromEnv(
|
|
198
|
+
env: InvestAppConfigEnvironment,
|
|
199
|
+
staticConfig: InvestRuntimeBrandConfig,
|
|
200
|
+
): InvestAppConfig;
|
|
201
|
+
export function createInvestAppConfigFromEnv(
|
|
202
|
+
env: InvestAppConfigEnvironment,
|
|
203
|
+
staticConfig?: InvestRuntimeBrandConfig,
|
|
204
|
+
): InvestAppBaseConfig | InvestAppConfig {
|
|
205
|
+
const evmApiUrl = String(env.EVM_URL ?? '');
|
|
206
|
+
const turnkeyServerSignUrl = String(
|
|
207
|
+
env.TURNKEY_SERVER_SIGN_URL ?? joinUrlPath(evmApiUrl, TURNKEY_SERVER_SIGN_PATH),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const baseConfig: InvestAppBaseConfig = {
|
|
211
|
+
env: String(env.ENV ?? env.MODE ?? ''),
|
|
212
|
+
isDev: env.DEV === true || env.DEV === 'true',
|
|
213
|
+
isStaticSite: env.IS_STATIC_SITE === true || env.IS_STATIC_SITE === 'true' || env.IS_STATIC_SITE === '1',
|
|
214
|
+
enableAnalytics: env.ENABLE_ANALYTICS === true || env.ENABLE_ANALYTICS === 'true' || env.ENABLE_ANALYTICS === '1',
|
|
215
|
+
cookieDomain: String(env.COOKIE_DOMAIN ?? ''),
|
|
216
|
+
stableCoinAddress: normalizeStableCoinAddress(env.STABLE_COIN),
|
|
217
|
+
urls: {
|
|
218
|
+
frontend: String(env.FRONTEND_URL ?? ''),
|
|
219
|
+
dashboard: String(env.FRONTEND_URL_DASHBOARD ?? ''),
|
|
220
|
+
static: String(env.FRONTEND_URL_STATIC ?? ''),
|
|
221
|
+
cryptoWalletScan: String(env.CRYPTO_WALLET_SCAN_URL ?? ''),
|
|
222
|
+
api: {
|
|
223
|
+
kratos: String(env.KRATOS_URL ?? ''),
|
|
224
|
+
plaid: String(env.PLAID_URL ?? ''),
|
|
225
|
+
investment: String(env.INVESTMENT_URL ?? ''),
|
|
226
|
+
offer: String(env.OFFER_URL ?? ''),
|
|
227
|
+
esign: String(env.ESIGN_URL ?? ''),
|
|
228
|
+
user: String(env.USER_URL ?? ''),
|
|
229
|
+
notification: String(env.NOTIFICATION_URL ?? ''),
|
|
230
|
+
accreditation: String(env.ACCREDITATION_URL ?? ''),
|
|
231
|
+
payments: String(env.PAYMENTS_URL ?? ''),
|
|
232
|
+
wallet: String(env.WALLET_URL ?? ''),
|
|
233
|
+
evm: evmApiUrl,
|
|
234
|
+
filer: String(env.FILER_URL ?? ''),
|
|
235
|
+
distributions: String(env.DISTRIBUTIONS_URL ?? ''),
|
|
236
|
+
analytic: String(env.ANALYTIC_URL ?? ''),
|
|
237
|
+
docuseal: String(env.DOCUSEAL_URL ?? ''),
|
|
238
|
+
fundManager: String(env.FUND_MANAGER_URL ?? ''),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
demoAccount: {
|
|
242
|
+
email: String(env.DEMO_ACCOUNT_EMAIL ?? ''),
|
|
243
|
+
password: String(env.DEMO_ACCOUNT_PASSWORD ?? ''),
|
|
244
|
+
},
|
|
245
|
+
build: {
|
|
246
|
+
version: String(env.APP_VERSION ?? ''),
|
|
247
|
+
timestamp: String(env.APP_BUILD_TIMESTAMP ?? ''),
|
|
248
|
+
},
|
|
249
|
+
thirdParty: {
|
|
250
|
+
hellosignClientId: String(env.HELLOSIGN_CLIENT_ID ?? ''),
|
|
251
|
+
segmentKey: String(env.SEGMENT_KEY ?? ''),
|
|
252
|
+
alchemyWalletApiKey: String(env.ALCHEMY_WALLET_API_KEY ?? ''),
|
|
253
|
+
alchemy7702PolicyId: String(env.ALCHEMY_7702_POLICY_ID ?? ''),
|
|
254
|
+
turnkeyApiBaseUrl: String(env.TURNKEY_API_BASE_URL ?? 'https://api.turnkey.com'),
|
|
255
|
+
turnkeyOrganizationId: String(env.TURNKEY_ORGANIZATION_ID ?? ''),
|
|
256
|
+
turnkeyServerSignUrl,
|
|
257
|
+
turnkeySessionExpirationSeconds: String(env.TURNKEY_SESSION_EXPIRATION_SECONDS ?? '3600'),
|
|
258
|
+
turnkeyRegistrationEnabled: String(env.TURNKEY_REGISTRATION_ENABLED ?? 'false'),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
if (!staticConfig) return baseConfig;
|
|
262
|
+
return { ...baseConfig, brand: { ...staticConfig.brand } };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const trimTrailingSlash = (value?: string) => String(value ?? '').replace(/\/$/, '');
|
|
266
|
+
|
|
267
|
+
export const createInvestAppLinks = (config: InvestAppLinkConfig) => {
|
|
268
|
+
const staticUrl = trimTrailingSlash(config.static);
|
|
269
|
+
const dashboardUrl = trimTrailingSlash(config.dashboard);
|
|
270
|
+
|
|
271
|
+
const profileTab = (profileId: number, tab: string) => (
|
|
272
|
+
`${dashboardUrl}/profile/${profileId}/account?tab=${tab}`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
home: `${staticUrl}/`,
|
|
277
|
+
signin: `${staticUrl}/signin`,
|
|
278
|
+
authenticator: `${staticUrl}/authenticator`,
|
|
279
|
+
signup: `${staticUrl}/signup`,
|
|
280
|
+
forgot: `${staticUrl}/forgot`,
|
|
281
|
+
resetPassword: `${dashboardUrl}/reset-password`,
|
|
282
|
+
checkEmail: `${staticUrl}/check-email`,
|
|
283
|
+
contactUs: `${staticUrl}/contact-us`,
|
|
284
|
+
offers: `${staticUrl}/offers`,
|
|
285
|
+
howItWorks: `${staticUrl}/how-it-works`,
|
|
286
|
+
faq: `${staticUrl}/faq`,
|
|
287
|
+
blog: `${staticUrl}/resource-center`,
|
|
288
|
+
terms: `${staticUrl}/legal/terms-of-use`,
|
|
289
|
+
privacy: `${staticUrl}/legal/privacy-policy`,
|
|
290
|
+
cookie: `${staticUrl}/legal/cookie`,
|
|
291
|
+
serverError: `${dashboardUrl}/500`,
|
|
292
|
+
notifications: `${dashboardUrl}/notifications`,
|
|
293
|
+
settings: (profileId: number) => `${dashboardUrl}/settings/${profileId}/mfa`,
|
|
294
|
+
settingsAccountDetails: (profileId: number) => `${dashboardUrl}/settings/${profileId}/account-details`,
|
|
295
|
+
settingsMfa: (profileId: number) => `${dashboardUrl}/settings/${profileId}/mfa`,
|
|
296
|
+
settingsSecurity: (profileId: number) => `${dashboardUrl}/settings/${profileId}/security`,
|
|
297
|
+
settingsBankAccounts: (profileId: number) => `${dashboardUrl}/settings/${profileId}/bank-accounts`,
|
|
298
|
+
profileAccreditation: (profileId: number) => `${dashboardUrl}/profile/${profileId}/accreditation`,
|
|
299
|
+
investmentTimeline: (profileId: number, investId: string) => (
|
|
300
|
+
`${dashboardUrl}/profile/${profileId}/investment/${investId}/timeline`
|
|
301
|
+
),
|
|
302
|
+
profileCryptoWallet: (profileId: number) => `${dashboardUrl}/profile/${profileId}/evmwallet`,
|
|
303
|
+
profileWallet: (profileId: number) => `${dashboardUrl}/profile/${profileId}/wallet`,
|
|
304
|
+
profileAccount: (profileId: number) => `${dashboardUrl}/profile/${profileId}/account`,
|
|
305
|
+
profileEarn: (profileId: number) => `${dashboardUrl}/profile/${profileId}/earn`,
|
|
306
|
+
earnOverview: (profileId: number, poolId: string | number) => (
|
|
307
|
+
`${dashboardUrl}/profile/${profileId}/earn/${poolId}/overview`
|
|
308
|
+
),
|
|
309
|
+
earnYourPosition: (profileId: number, poolId: string | number) => (
|
|
310
|
+
`${dashboardUrl}/profile/${profileId}/earn/${poolId}/your-position`
|
|
311
|
+
),
|
|
312
|
+
earnRisk: (profileId: number, poolId: string | number) => (
|
|
313
|
+
`${dashboardUrl}/profile/${profileId}/earn/${poolId}/risk`
|
|
314
|
+
),
|
|
315
|
+
profileKyc: (profileId: number) => `${dashboardUrl}/profile/${profileId}/kyc`,
|
|
316
|
+
profileWalletOtp: (profileId: number) => `${dashboardUrl}/profile/${profileId}/wallet-otp`,
|
|
317
|
+
profilePortfolio: (profileId: number) => `${dashboardUrl}/profile/${profileId}/portfolio`,
|
|
318
|
+
profileSummary: (profileId: number) => `${dashboardUrl}/profile/${profileId}/summary`,
|
|
319
|
+
profile: () => `${dashboardUrl}/profile`,
|
|
320
|
+
createProfile: () => `${dashboardUrl}/profile/create-new-profile`,
|
|
321
|
+
offerSingle: (slug: string) => `${staticUrl}/${slug}`,
|
|
322
|
+
blogSingle: (slug: string) => `${staticUrl}/resource-center/${slug}`,
|
|
323
|
+
profileTabSummary: (profileId: number) => profileTab(profileId, 'summary'),
|
|
324
|
+
profileTabPortfolio: (profileId: number) => profileTab(profileId, 'portfolio'),
|
|
325
|
+
profileTabWallet: (profileId: number) => profileTab(profileId, 'wallet'),
|
|
326
|
+
profileTabDistributions: (profileId: number) => profileTab(profileId, 'distributions'),
|
|
327
|
+
profileTabEarn: (profileId: number) => profileTab(profileId, 'earn'),
|
|
328
|
+
};
|
|
329
|
+
};
|