@marvalt/digivalt-core 0.1.0 โ†’ 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marvalt/digivalt-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Core glue logic and shared context for DigiVAlt frontend applications",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "main": "dist/index.cjs",
@@ -13,11 +13,16 @@
13
13
  "require": "./dist/index.cjs"
14
14
  }
15
15
  },
16
+ "bin": {
17
+ "digivalt-init": "./bin/init.cjs"
18
+ },
16
19
  "files": [
17
20
  "dist",
18
21
  "README.md",
19
22
  "LICENSE",
20
- "CHANGELOG.md"
23
+ "CHANGELOG.md",
24
+ "bin",
25
+ "template"
21
26
  ],
22
27
  "type": "module",
23
28
  "sideEffects": false,
@@ -0,0 +1,11 @@
1
+ # DigiVAlt Cloudflare Worker Secrets
2
+ # Copy this to .dev.vars and populate for local testing via Wrangler
3
+
4
+ # Add the specific API keys needed by your functions/ proxy layers
5
+ MAUTIC_USERNAME=
6
+ MAUTIC_PASSWORD=
7
+ SUITECRM_CLIENT_ID=
8
+ SUITECRM_CLIENT_SECRET=
9
+ GRAVITY_FORMS_CONSUMER_KEY=
10
+ GRAVITY_FORMS_CONSUMER_SECRET=
11
+ WP_APPLICATION_PASSWORD=
@@ -0,0 +1,41 @@
1
+ export async function onRequest(context: any) {
2
+ const { request, env } = context;
3
+
4
+ try {
5
+ const url = new URL(request.url);
6
+ const target = url.searchParams.get('target');
7
+ if (!target) return new Response('Missing target', { status: 400 });
8
+
9
+ // Server-side secrets (do NOT expose to browser). Prefer non-VITE names.
10
+ const clientId = env.CF_ACCESS_CLIENT_ID || env.VITE_CF_ACCESS_CLIENT_ID;
11
+ const clientSecret = env.CF_ACCESS_CLIENT_SECRET || env.VITE_CF_ACCESS_CLIENT_SECRET;
12
+
13
+ if (!clientId || !clientSecret) {
14
+ return new Response('Access credentials not configured', { status: 500 });
15
+ }
16
+
17
+ const method = request.method;
18
+ const incomingHeaders = new Headers(request.headers);
19
+ // Strip hop-by-hop and sensitive headers
20
+ ['host', 'origin', 'referer', 'cookie', 'authorization'].forEach((h) => incomingHeaders.delete(h));
21
+
22
+ // Inject Cloudflare Access service token headers
23
+ incomingHeaders.set('CF-Access-Client-Id', clientId);
24
+ incomingHeaders.set('CF-Access-Client-Secret', clientSecret);
25
+
26
+ const init: RequestInit = { method, headers: incomingHeaders };
27
+ if (method !== 'GET' && method !== 'HEAD') {
28
+ init.body = await request.arrayBuffer();
29
+ }
30
+
31
+ const resp = await fetch(target, init);
32
+ const respHeaders = new Headers(resp.headers);
33
+ ['set-cookie', 'cf-ray', 'server'].forEach((h) => respHeaders.delete(h));
34
+
35
+ return new Response(resp.body, { status: resp.status, headers: respHeaders });
36
+ } catch (e: any) {
37
+ return new Response(`fetch-with-access error: ${e?.message || 'unknown'}`, { status: 500 });
38
+ }
39
+ }
40
+
41
+
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Cloudflare Pages Function for Gravity Forms API proxy
3
+ *
4
+ * This function handles WordPress Basic Auth server-side to keep credentials secure.
5
+ * It provides the same security layers as the Mautic proxy.
6
+ *
7
+ * Required environment variables:
8
+ * - VITE_WORDPRESS_API_URL or WORDPRESS_API_URL: WordPress instance URL
9
+ * - VITE_WP_API_USERNAME or WP_API_USERNAME: WordPress username
10
+ * - VITE_WP_APP_PASSWORD or WP_APP_PASSWORD: WordPress application password
11
+ * - VITE_CF_ACCESS_CLIENT_ID or CF_ACCESS_CLIENT_ID: (Optional) Cloudflare Access client ID
12
+ * - VITE_CF_ACCESS_CLIENT_SECRET or CF_ACCESS_CLIENT_SECRET: (Optional) Cloudflare Access client secret
13
+ * - ALLOWED_ORIGINS or VITE_ALLOWED_ORIGINS: Comma-separated list of allowed origins
14
+ * - TURNSTILE_SECRET_KEY or VITE_TURNSTILE_SECRET_KEY: (Optional) Cloudflare Turnstile secret key
15
+ */
16
+
17
+ /**
18
+ * Verifies a Cloudflare Turnstile token server-side.
19
+ */
20
+ async function verifyTurnstile(token: string, secretKey: string): Promise<boolean> {
21
+ const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
22
+ method: 'POST',
23
+ headers: {
24
+ 'Content-Type': 'application/x-www-form-urlencoded',
25
+ },
26
+ body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
27
+ });
28
+
29
+ const data = await response.json();
30
+ return data.success;
31
+ }
32
+
33
+ export async function onRequest(context: any) {
34
+ const { request, env } = context;
35
+
36
+ try {
37
+ const url = new URL(request.url);
38
+ const endpoint = url.searchParams.get('endpoint');
39
+
40
+ // ============================================
41
+ // CORS Headers - Always include for OPTIONS preflight
42
+ // ============================================
43
+ const origin = request.headers.get('Origin');
44
+ const referer = request.headers.get('Referer');
45
+
46
+ const allowedOriginsStr = env.ALLOWED_ORIGINS || env.VITE_ALLOWED_ORIGINS || '';
47
+ const allowedOrigins = allowedOriginsStr
48
+ .split(',')
49
+ .map((o: string) => o.trim())
50
+ .filter(Boolean);
51
+
52
+ if (allowedOrigins.length === 0) {
53
+ allowedOrigins.push('http://localhost:8080', 'http://localhost:5173');
54
+ console.log('โš ๏ธ No ALLOWED_ORIGINS configured, defaulting to localhost');
55
+ }
56
+
57
+ // Check if origin is in allowed list OR is localhost (development)
58
+ const isLocalhost = origin && (origin.includes('localhost') || origin.includes('127.0.0.1'));
59
+ const isAllowedOrigin = allowedOrigins.some((allowed: string) =>
60
+ origin?.startsWith(allowed) || referer?.startsWith(allowed)
61
+ ) || isLocalhost; // Allow localhost in development
62
+
63
+ // Build CORS headers - always include these
64
+ const corsHeaders: Record<string, string> = {
65
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
66
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, CF-Access-Client-Id, CF-Access-Client-Secret',
67
+ 'Access-Control-Max-Age': '86400',
68
+ };
69
+
70
+ // Determine the origin to allow
71
+ let allowedOrigin: string | null = null;
72
+ if (origin && isAllowedOrigin) {
73
+ allowedOrigin = origin;
74
+ if (isLocalhost) {
75
+ console.log('๐Ÿ”“ Allowing localhost origin in development:', origin);
76
+ }
77
+ } else if (referer && isAllowedOrigin) {
78
+ // Fallback to referer if origin is not present
79
+ try {
80
+ allowedOrigin = new URL(referer).origin;
81
+ } catch (e) {
82
+ // Invalid referer URL, ignore
83
+ }
84
+ } else if (!origin && !referer) {
85
+ // Allow if no origin/referer (same-origin or direct request)
86
+ allowedOrigin = '*';
87
+ }
88
+
89
+ // Set Access-Control-Allow-Origin header
90
+ if (allowedOrigin) {
91
+ corsHeaders['Access-Control-Allow-Origin'] = allowedOrigin;
92
+ }
93
+
94
+ // Handle OPTIONS preflight request
95
+ if (request.method === 'OPTIONS') {
96
+ return new Response(null, {
97
+ status: 204,
98
+ headers: corsHeaders,
99
+ });
100
+ }
101
+
102
+ if (!endpoint) {
103
+ return new Response('Missing endpoint parameter', {
104
+ status: 400,
105
+ headers: corsHeaders,
106
+ });
107
+ }
108
+
109
+ // Validate origin for non-OPTIONS requests (use the same check that includes localhost)
110
+ if ((origin || referer) && !isAllowedOrigin) {
111
+ console.warn('๐Ÿšซ Blocked request from unauthorized origin:', origin || referer);
112
+ return new Response(JSON.stringify({
113
+ error: 'Forbidden origin',
114
+ message: 'This endpoint can only be accessed from authorized domains'
115
+ }), {
116
+ status: 403,
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ ...corsHeaders,
120
+ }
121
+ });
122
+ }
123
+
124
+ // ============================================
125
+ // SECURITY LAYER 2: Endpoint Whitelisting
126
+ // ============================================
127
+ const allowedPatterns = [
128
+ /^\/forms\/\d+\/submit$/, // Form submissions only
129
+ ];
130
+
131
+ const isAllowedEndpoint = allowedPatterns.some(pattern => pattern.test(endpoint));
132
+
133
+ if (!isAllowedEndpoint) {
134
+ console.warn('๐Ÿšซ Blocked unauthorized endpoint:', endpoint);
135
+ return new Response(JSON.stringify({
136
+ error: 'Forbidden endpoint',
137
+ message: 'Only form submission endpoints are allowed'
138
+ }), {
139
+ status: 403,
140
+ headers: { 'Content-Type': 'application/json' }
141
+ });
142
+ }
143
+
144
+ // ============================================
145
+ // SECURITY LAYER 3: Turnstile Verification
146
+ // ============================================
147
+ const turnstileSecretKey = env.TURNSTILE_SECRET_KEY || env.VITE_TURNSTILE_SECRET_KEY;
148
+ const turnstileToken = request.headers.get('cf-turnstile-response');
149
+
150
+ // Only verify Turnstile if:
151
+ // 1. Secret key is configured AND
152
+ // 2. Request is POST AND
153
+ // 3. Client sent a token (indicating Turnstile is enabled on the form)
154
+ if (turnstileSecretKey && request.method === 'POST' && turnstileToken) {
155
+ const isValid = await verifyTurnstile(turnstileToken, turnstileSecretKey);
156
+
157
+ if (!isValid) {
158
+ console.warn('๐Ÿšซ Invalid Turnstile token');
159
+ return new Response(JSON.stringify({
160
+ error: 'Verification failed',
161
+ message: 'Bot verification failed'
162
+ }), {
163
+ status: 403,
164
+ headers: { 'Content-Type': 'application/json' }
165
+ });
166
+ }
167
+
168
+ console.log('โœ… Turnstile verification passed');
169
+ } else if (turnstileSecretKey && request.method === 'POST' && !turnstileToken) {
170
+ // Warn but allow (Turnstile is optional for Gravity Forms)
171
+ console.warn('โš ๏ธ Turnstile secret key configured but no token provided - allowing request');
172
+ }
173
+
174
+ // ============================================
175
+ // SECURITY LAYER 4: WordPress Basic Auth
176
+ // ============================================
177
+ const wpUrl = env.VITE_WORDPRESS_API_URL || env.WORDPRESS_API_URL;
178
+ const username = env.VITE_WP_API_USERNAME || env.WP_API_USERNAME;
179
+ const password = env.VITE_WP_APP_PASSWORD || env.WP_APP_PASSWORD;
180
+
181
+ if (!wpUrl || !username || !password) {
182
+ console.error('โŒ WordPress credentials not configured', {
183
+ wpUrl: !!wpUrl,
184
+ username: !!username,
185
+ password: !!password
186
+ });
187
+ return new Response('WordPress credentials not configured', { status: 500 });
188
+ }
189
+
190
+ // Create Basic Auth header
191
+ const authHeader = 'Basic ' + btoa(`${username}:${password}`);
192
+
193
+ const headers: Record<string, string> = {
194
+ 'Authorization': authHeader,
195
+ };
196
+
197
+ // Preserve Content-Type from original request
198
+ const contentType = request.headers.get('Content-Type');
199
+ if (contentType) {
200
+ headers['Content-Type'] = contentType;
201
+ }
202
+
203
+ // ============================================
204
+ // SECURITY LAYER 5: CF Access (Optional)
205
+ // ============================================
206
+ const cfAccessClientId = env.CF_ACCESS_CLIENT_ID || env.VITE_CF_ACCESS_CLIENT_ID;
207
+ const cfAccessClientSecret = env.CF_ACCESS_CLIENT_SECRET || env.VITE_CF_ACCESS_CLIENT_SECRET;
208
+
209
+ if (cfAccessClientId && cfAccessClientSecret) {
210
+ headers['CF-Access-Client-Id'] = cfAccessClientId;
211
+ headers['CF-Access-Client-Secret'] = cfAccessClientSecret;
212
+ console.log('๐Ÿ” Added CF Access headers to WordPress request');
213
+ }
214
+
215
+ // Build target URL
216
+ const targetUrl = `${wpUrl}/wp-json/gf-api/v1${endpoint}`;
217
+
218
+ const init: RequestInit = {
219
+ method: request.method,
220
+ headers,
221
+ };
222
+
223
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
224
+ init.body = await request.text();
225
+ }
226
+
227
+ console.log(`๐Ÿ“ค Proxying ${request.method} request to WordPress:`, {
228
+ endpoint,
229
+ targetUrl,
230
+ bodyPreview: init.body ? init.body.substring(0, 200) : 'no body'
231
+ });
232
+
233
+ const response = await fetch(targetUrl, init);
234
+
235
+ const responseBody = await response.text();
236
+ console.log(`๐Ÿ“ฅ WordPress response: ${response.status} ${response.statusText}`, {
237
+ bodyPreview: responseBody.substring(0, 500)
238
+ });
239
+
240
+ return new Response(responseBody, {
241
+ status: response.status,
242
+ statusText: response.statusText,
243
+ headers: {
244
+ 'Content-Type': response.headers.get('Content-Type') || 'application/json',
245
+ ...corsHeaders,
246
+ },
247
+ });
248
+
249
+ } catch (error: any) {
250
+ console.error('โŒ Gravity Forms proxy error:', error);
251
+ // Build CORS headers for error response (reuse logic from above)
252
+ const errorCorsHeaders: Record<string, string> = {
253
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
254
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, CF-Access-Client-Id, CF-Access-Client-Secret',
255
+ 'Access-Control-Max-Age': '86400',
256
+ };
257
+ const errorOrigin = request.headers.get('Origin');
258
+ const errorReferer = request.headers.get('Referer');
259
+ const errorAllowedOriginsStr = env.ALLOWED_ORIGINS || env.VITE_ALLOWED_ORIGINS || '';
260
+ const errorAllowedOrigins = errorAllowedOriginsStr
261
+ .split(',')
262
+ .map((o: string) => o.trim())
263
+ .filter(Boolean);
264
+ if (errorAllowedOrigins.length === 0) {
265
+ errorAllowedOrigins.push('http://localhost:8080', 'http://localhost:5173');
266
+ }
267
+ const isErrorAllowedOrigin = errorAllowedOrigins.some((allowed: string) =>
268
+ errorOrigin?.startsWith(allowed) || errorReferer?.startsWith(allowed)
269
+ );
270
+ if (errorOrigin && isErrorAllowedOrigin) {
271
+ errorCorsHeaders['Access-Control-Allow-Origin'] = errorOrigin;
272
+ } else if (errorReferer && isErrorAllowedOrigin) {
273
+ const errorRefererOrigin = new URL(errorReferer).origin;
274
+ errorCorsHeaders['Access-Control-Allow-Origin'] = errorRefererOrigin;
275
+ } else if (!errorOrigin && !errorReferer) {
276
+ errorCorsHeaders['Access-Control-Allow-Origin'] = '*';
277
+ }
278
+
279
+ return new Response(JSON.stringify({
280
+ success: false,
281
+ error: error?.message || 'Unknown error',
282
+ }), {
283
+ status: 500,
284
+ headers: {
285
+ 'Content-Type': 'application/json',
286
+ ...errorCorsHeaders,
287
+ },
288
+ });
289
+ }
290
+ }
291
+
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cloudflare Pages Function for Mautic API proxy
3
+ * Auto-generated by @marvalt/madapter
4
+ *
5
+ * This function handles OAuth2 authentication server-side to keep credentials secure.
6
+ * It is automatically installed when you install @marvalt/madapter.
7
+ *
8
+ * Required environment variables (in .env.local):
9
+ * - VITE_MAUTIC_URL: Your Mautic instance URL
10
+ * - VITE_MAUTIC_API_PUBLIC_KEY: OAuth2 client ID
11
+ * - VITE_MAUTIC_API_SECRET_KEY: OAuth2 client secret
12
+ * - VITE_CF_ACCESS_CLIENT_ID: (Optional) Cloudflare Access client ID
13
+ * - VITE_CF_ACCESS_CLIENT_SECRET: (Optional) Cloudflare Access client secret
14
+ */
15
+
16
+ import { handleMauticProxy } from '@marvalt/madapter/server';
17
+
18
+ export const onRequest = handleMauticProxy;
19
+
@@ -0,0 +1,78 @@
1
+ export async function onRequestPost(context) {
2
+ const { request, env } = context;
3
+
4
+ try {
5
+ console.log('Webhook received:', {
6
+ method: request.method,
7
+ url: request.url,
8
+ headers: Object.fromEntries(request.headers.entries())
9
+ });
10
+
11
+ // Verify authentication using VITE_FRONTEND_SECRET
12
+ const authHeader = request.headers.get('Authorization');
13
+ const expectedSecret = env.VITE_FRONTEND_SECRET;
14
+
15
+ if (!authHeader || !expectedSecret) {
16
+ console.log('Missing authentication:', {
17
+ hasAuthHeader: !!authHeader,
18
+ hasExpectedSecret: !!expectedSecret
19
+ });
20
+ return new Response('Unauthorized', { status: 401 });
21
+ }
22
+
23
+ // Check if the Authorization header matches the expected secret
24
+ if (authHeader !== `Bearer ${expectedSecret}`) {
25
+ console.log('Invalid authentication token');
26
+ return new Response('Unauthorized', { status: 401 });
27
+ }
28
+
29
+ // Parse the webhook payload
30
+ const payload = await request.json();
31
+ console.log('Webhook payload:', payload);
32
+
33
+ // Validate required fields
34
+ if (!payload.frontend_id || !payload.post_id || !payload.post_type) {
35
+ console.log('Missing required fields:', payload);
36
+ return new Response('Bad Request - Missing required fields', { status: 400 });
37
+ }
38
+
39
+ // Log the webhook details
40
+ console.log('Webhook processed successfully:', {
41
+ frontend_id: payload.frontend_id,
42
+ post_id: payload.post_id,
43
+ post_type: payload.post_type,
44
+ action: payload.action || 'update',
45
+ timestamp: new Date().toISOString()
46
+ });
47
+
48
+ // For now, we'll just log the webhook
49
+ // In the future, this could trigger a rebuild or cache invalidation
50
+ // For Cloudflare Pages, you might want to trigger a deployment webhook
51
+
52
+ return new Response(JSON.stringify({
53
+ success: true,
54
+ message: 'Webhook processed successfully',
55
+ timestamp: new Date().toISOString()
56
+ }), {
57
+ status: 200,
58
+ headers: {
59
+ 'Content-Type': 'application/json'
60
+ }
61
+ });
62
+
63
+ } catch (error) {
64
+ console.error('Webhook error:', error);
65
+ return new Response(JSON.stringify({
66
+ success: false,
67
+ error: error.message
68
+ }), {
69
+ status: 500,
70
+ headers: {
71
+ 'Content-Type': 'application/json'
72
+ }
73
+ });
74
+ }
75
+ }
76
+
77
+
78
+
@@ -0,0 +1,85 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+
5
+ /**
6
+ * ๐Ÿš€ DigiVAlt Cloudflare Secrets Deployer
7
+ *
8
+ * Automates the synchronization of local .env.local variables
9
+ * securely straight into your Cloudflare Pages environment
10
+ * using Wrangler bulk secret injection.
11
+ */
12
+
13
+ const envPath = path.resolve(process.cwd(), '.env.local');
14
+
15
+ if (!fs.existsSync(envPath)) {
16
+ console.error("โŒ No .env.local file found in the root directory!");
17
+ console.log("๐Ÿ‘‰ Please create a .env.local file with your production secrets.");
18
+ process.exit(1);
19
+ }
20
+
21
+ console.log("๐Ÿ” Parsing .env.local secrets...");
22
+ const rawEnv = fs.readFileSync(envPath, 'utf8');
23
+ const secrets = {};
24
+
25
+ rawEnv.split('\n').forEach(line => {
26
+ const trimmed = line.trim();
27
+ // Ignore comments and empty lines
28
+ if (!trimmed || trimmed.startsWith('#')) return;
29
+
30
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
31
+ if (match) {
32
+ const key = match[1].trim();
33
+ let value = match[2].trim();
34
+
35
+ // Strip surrounding quotes if present
36
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
37
+ if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
38
+
39
+ secrets[key] = value;
40
+ }
41
+ });
42
+
43
+ const secretCount = Object.keys(secrets).length;
44
+ if (secretCount === 0) {
45
+ console.log("โš ๏ธ No valid SECRETS found in .env.local. Exiting.");
46
+ process.exit(0);
47
+ }
48
+
49
+ const tmpJsonPath = path.resolve(process.cwd(), '.tmp-cloudflare-secrets.json');
50
+ fs.writeFileSync(tmpJsonPath, JSON.stringify(secrets, null, 2));
51
+
52
+ try {
53
+ console.log(`๐Ÿš€ Uploading ${secretCount} secrets to Cloudflare Pages...`);
54
+
55
+ // Try to read the Cloudflare project name from wrangler.toml
56
+ const tomlPath = path.resolve(process.cwd(), 'wrangler.toml');
57
+ let projectName = '';
58
+
59
+ if (fs.existsSync(tomlPath)) {
60
+ const tomlContent = fs.readFileSync(tomlPath, 'utf8');
61
+ const nameMatch = tomlContent.match(/^name\s*=\s*"([^"]+)"/m);
62
+ if (nameMatch) {
63
+ projectName = nameMatch[1];
64
+ console.log(`๐Ÿ“ฆ Targeted Cloudflare Project: ${projectName}`);
65
+ }
66
+ }
67
+
68
+ const cmd = projectName
69
+ ? `npx wrangler pages secret bulk .tmp-cloudflare-secrets.json --project-name ${projectName}`
70
+ : `npx wrangler pages secret bulk .tmp-cloudflare-secrets.json`;
71
+
72
+ // Execute Wrangler natively
73
+ execSync(cmd, { stdio: 'inherit' });
74
+ console.log("โœ… All secrets deployed successfully to Cloudflare!");
75
+
76
+ } catch (error) {
77
+ console.error("โŒ Failed to push secrets to Cloudflare.");
78
+ console.error("Ensure you are logged into Wrangler (`npx wrangler login`) and the project exists.");
79
+ } finally {
80
+ // Always rigorously clean up the temporary plaintext JSON file for security
81
+ if (fs.existsSync(tmpJsonPath)) {
82
+ fs.unlinkSync(tmpJsonPath);
83
+ console.log("๐Ÿงน Cleaned up temporary credentials payload.");
84
+ }
85
+ }
@@ -0,0 +1,3 @@
1
+ name = "digivalt-landing-api"
2
+ pages_build_output_dir = "dist"
3
+ compatibility_date = "2024-03-20"