@kya-os/mcp-i-cloudflare 1.5.8-canary.4 → 1.5.8-canary.40

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.
Files changed (67) hide show
  1. package/README.md +130 -0
  2. package/dist/__tests__/e2e/test-config.d.ts +37 -0
  3. package/dist/__tests__/e2e/test-config.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/test-config.js +62 -0
  5. package/dist/__tests__/e2e/test-config.js.map +1 -0
  6. package/dist/adapter.d.ts +44 -0
  7. package/dist/adapter.d.ts.map +1 -1
  8. package/dist/adapter.js +655 -87
  9. package/dist/adapter.js.map +1 -1
  10. package/dist/agent.d.ts +8 -1
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +114 -5
  13. package/dist/agent.js.map +1 -1
  14. package/dist/app.d.ts.map +1 -1
  15. package/dist/app.js +19 -3
  16. package/dist/app.js.map +1 -1
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +33 -4
  19. package/dist/config.js.map +1 -1
  20. package/dist/helpers/env-mapper.d.ts +60 -1
  21. package/dist/helpers/env-mapper.d.ts.map +1 -1
  22. package/dist/helpers/env-mapper.js +136 -6
  23. package/dist/helpers/env-mapper.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/audit-logger.d.ts +96 -0
  29. package/dist/runtime/audit-logger.d.ts.map +1 -0
  30. package/dist/runtime/audit-logger.js +276 -0
  31. package/dist/runtime/audit-logger.js.map +1 -0
  32. package/dist/runtime/oauth-handler.d.ts +5 -0
  33. package/dist/runtime/oauth-handler.d.ts.map +1 -1
  34. package/dist/runtime/oauth-handler.js +152 -35
  35. package/dist/runtime/oauth-handler.js.map +1 -1
  36. package/dist/runtime.d.ts +12 -1
  37. package/dist/runtime.d.ts.map +1 -1
  38. package/dist/runtime.js +34 -4
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +7 -1
  42. package/dist/server.js.map +1 -1
  43. package/dist/services/admin.service.d.ts.map +1 -1
  44. package/dist/services/admin.service.js +15 -1
  45. package/dist/services/admin.service.js.map +1 -1
  46. package/dist/services/consent-audit.service.d.ts +91 -0
  47. package/dist/services/consent-audit.service.d.ts.map +1 -0
  48. package/dist/services/consent-audit.service.js +243 -0
  49. package/dist/services/consent-audit.service.js.map +1 -0
  50. package/dist/services/consent-config.service.d.ts +2 -2
  51. package/dist/services/consent-config.service.d.ts.map +1 -1
  52. package/dist/services/consent-config.service.js +55 -24
  53. package/dist/services/consent-config.service.js.map +1 -1
  54. package/dist/services/consent.service.d.ts +49 -1
  55. package/dist/services/consent.service.d.ts.map +1 -1
  56. package/dist/services/consent.service.js +1491 -28
  57. package/dist/services/consent.service.js.map +1 -1
  58. package/dist/services/delegation.service.d.ts.map +1 -1
  59. package/dist/services/delegation.service.js +67 -29
  60. package/dist/services/delegation.service.js.map +1 -1
  61. package/dist/services/proof.service.d.ts +5 -3
  62. package/dist/services/proof.service.d.ts.map +1 -1
  63. package/dist/services/proof.service.js +35 -8
  64. package/dist/services/proof.service.js.map +1 -1
  65. package/dist/types.d.ts +30 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/package.json +13 -9
@@ -15,17 +15,163 @@ import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
15
15
  import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
16
16
  import { UserDidManager } from "@kya-os/mcp-i-core";
17
17
  import { WebCryptoProvider } from "../providers/crypto";
18
+ import { ConsentAuditService } from "./consent-audit.service";
19
+ import { CloudflareProofGenerator } from "../proof-generator";
20
+ import { ProofService } from "./proof.service";
21
+ import { fetchRemoteConfig, } from "@kya-os/mcp-i-core";
18
22
  export class ConsentService {
19
23
  configService;
20
24
  renderer;
21
25
  env;
22
26
  runtime;
23
27
  userDidManager; // Cached instance for consistent DID generation
28
+ // ✅ Audit service - lazy initialized
29
+ auditService;
30
+ auditInitPromise; // Cache promise to prevent race conditions
31
+ /**
32
+ * ✅ FIXED: Constructor takes env: CloudflareEnv, not config
33
+ */
24
34
  constructor(env, runtime) {
25
35
  this.env = env;
26
36
  this.runtime = runtime;
27
37
  this.configService = new ConsentConfigService(env);
28
38
  this.renderer = new ConsentPageRenderer();
39
+ // No initialization here - keep constructor synchronous
40
+ }
41
+ /**
42
+ * Get or initialize audit service (lazy initialization)
43
+ *
44
+ * Fetches config from remote API when projectId is available.
45
+ * Uses promise caching to prevent race conditions.
46
+ *
47
+ * @param projectId - Project ID from consent request (required for config fetch)
48
+ */
49
+ async getAuditService(projectId) {
50
+ // Already initialized
51
+ if (this.auditService) {
52
+ return this.auditService;
53
+ }
54
+ // No runtime - audit not available
55
+ if (!this.runtime) {
56
+ return undefined;
57
+ }
58
+ // Initialization in progress - wait for it
59
+ if (this.auditInitPromise) {
60
+ await this.auditInitPromise;
61
+ return this.auditService;
62
+ }
63
+ // Start initialization (with projectId for config fetch)
64
+ this.auditInitPromise = this.initializeAuditService(projectId);
65
+ try {
66
+ await this.auditInitPromise;
67
+ }
68
+ catch (error) {
69
+ console.warn("[ConsentService] Audit service initialization failed:", error);
70
+ // Don't throw - audit failures shouldn't break consent flow
71
+ }
72
+ return this.auditService;
73
+ }
74
+ /**
75
+ * Initialize audit service - fetches config from remote API
76
+ *
77
+ * ⚠️ CRITICAL: Fetches config from remote API using fetchRemoteConfig()
78
+ * This is the ONLY way to get CloudflareRuntimeConfig per requirement.
79
+ */
80
+ async initializeAuditService(projectId) {
81
+ if (!this.runtime) {
82
+ return;
83
+ }
84
+ try {
85
+ // ✅ CRITICAL: Fetch config from remote API
86
+ const config = await this.getConfigFromRemoteAPI(projectId);
87
+ if (!config?.proofing?.enabled) {
88
+ console.log("[ConsentService] Proofing not enabled in remote config");
89
+ return; // Proofing not enabled
90
+ }
91
+ // Get identity (async - requires runtime to be initialized)
92
+ const identity = await this.runtime.getIdentity();
93
+ // ✅ FIXED: CloudflareProofGenerator only takes identity, not providers
94
+ const proofGenerator = new CloudflareProofGenerator(identity);
95
+ // Get audit logger
96
+ const auditLogger = this.runtime.getAuditLogger();
97
+ if (!auditLogger) {
98
+ console.warn("[ConsentService] AuditLogger not available");
99
+ return;
100
+ }
101
+ // Create audit service with fetched config
102
+ this.auditService = new ConsentAuditService(new ProofService(config, this.runtime), auditLogger, proofGenerator, config, // ✅ Config fetched from remote API
103
+ this.runtime);
104
+ console.log("[ConsentService] Audit service initialized successfully");
105
+ }
106
+ catch (error) {
107
+ console.error("[ConsentService] Failed to initialize audit service:", error);
108
+ // Don't throw - audit failures shouldn't break consent flow
109
+ }
110
+ }
111
+ /**
112
+ * Fetch CloudflareRuntimeConfig from remote API (AgentShield)
113
+ *
114
+ * ⚠️ CRITICAL: Config MUST be fetched from remote API, not constructed from env.
115
+ *
116
+ * Uses existing `fetchRemoteConfig()` from `@kya-os/mcp-i-core/config/remote-config`
117
+ * which handles caching, error handling, and API communication.
118
+ *
119
+ * @param projectId - Project ID from consent request
120
+ * @returns Runtime config or undefined if unavailable
121
+ */
122
+ async getConfigFromRemoteAPI(projectId) {
123
+ if (!this.env.AGENTSHIELD_API_KEY) {
124
+ console.warn("[ConsentService] No API key for runtime config fetch");
125
+ return undefined;
126
+ }
127
+ try {
128
+ // Create KV cache adapter
129
+ const cache = this.env.TOOL_PROTECTION_KV
130
+ ? {
131
+ get: async (key) => {
132
+ return ((await this.env.TOOL_PROTECTION_KV.get(key, "text")) || null);
133
+ },
134
+ set: async (key, value, ttl) => {
135
+ await this.env.TOOL_PROTECTION_KV.put(key, value, {
136
+ expirationTtl: Math.floor(ttl / 1000),
137
+ });
138
+ },
139
+ }
140
+ : undefined;
141
+ const config = await fetchRemoteConfig({
142
+ apiUrl: this.env.AGENTSHIELD_API_URL || "https://kya.vouched.id",
143
+ apiKey: this.env.AGENTSHIELD_API_KEY,
144
+ projectId, // ✅ Use projectId from consent request
145
+ cacheTtl: 300000, // 5 minutes
146
+ fetchProvider: fetch,
147
+ }, cache);
148
+ // Populate serverDid from runtime identity if missing or empty
149
+ // This prevents AgentShield validation errors (serverDid must be at least 1 character)
150
+ // Note: MCPIConfig.identity is RuntimeIdentityConfig (no serverDid), but AgentShield
151
+ // may return MCPIServerConfig format (has serverDid). We need to handle both.
152
+ if (config && config.identity) {
153
+ const identityConfig = config.identity; // Type assertion for serverDid field
154
+ if (!identityConfig.serverDid || identityConfig.serverDid === "") {
155
+ try {
156
+ const runtimeIdentity = await this.runtime?.getIdentity();
157
+ if (runtimeIdentity?.did) {
158
+ identityConfig.serverDid = runtimeIdentity.did;
159
+ console.log("[ConsentService] Populated serverDid from runtime identity");
160
+ }
161
+ }
162
+ catch (error) {
163
+ console.warn("[ConsentService] Failed to get runtime identity for serverDid:", error);
164
+ }
165
+ }
166
+ }
167
+ // fetchRemoteConfig returns MCPIConfig | null
168
+ // CloudflareRuntimeConfig extends MCPIConfig, so cast is safe
169
+ return config;
170
+ }
171
+ catch (error) {
172
+ console.warn("[ConsentService] Error fetching runtime config:", error);
173
+ return undefined;
174
+ }
29
175
  }
30
176
  /**
31
177
  * Get or generate User DID for a session
@@ -38,13 +184,21 @@ export class ConsentService {
38
184
  * @returns User DID (did:key format)
39
185
  */
40
186
  async getUserDidForSession(sessionId, oauthIdentity) {
187
+ // Handle null explicitly (from JSON parsing)
188
+ const hasOAuthIdentity = oauthIdentity &&
189
+ typeof oauthIdentity === "object" &&
190
+ oauthIdentity.provider &&
191
+ oauthIdentity.subject;
41
192
  // If OAuth identity provided, check for existing mapping first
42
- if (oauthIdentity && this.env.DELEGATION_STORAGE) {
193
+ if (hasOAuthIdentity && this.env.DELEGATION_STORAGE) {
43
194
  try {
44
195
  const oauthKey = STORAGE_KEYS.oauthIdentity(oauthIdentity.provider, oauthIdentity.subject);
45
196
  const mappedUserDid = await this.env.DELEGATION_STORAGE.get(oauthKey, "text");
46
197
  if (mappedUserDid) {
47
- console.log("[ConsentService] Found persistent User DID from OAuth mapping");
198
+ console.log("[ConsentService] Found persistent User DID from OAuth mapping:", {
199
+ provider: oauthIdentity.provider,
200
+ userDid: mappedUserDid.substring(0, 20) + "...",
201
+ });
48
202
  return mappedUserDid;
49
203
  }
50
204
  }
@@ -53,6 +207,10 @@ export class ConsentService {
53
207
  // Continue with ephemeral DID generation
54
208
  }
55
209
  }
210
+ else if (oauthIdentity === null) {
211
+ // Explicitly handle null case (no OAuth)
212
+ console.log("[ConsentService] No OAuth identity provided (null), generating ephemeral DID");
213
+ }
56
214
  // Continue with existing ephemeral DID generation logic
57
215
  if (!this.env.DELEGATION_STORAGE) {
58
216
  // No storage - use cached UserDidManager instance for consistent DID generation
@@ -96,10 +254,34 @@ export class ConsentService {
96
254
  delete: async (key) => {
97
255
  await this.env.DELEGATION_STORAGE.delete(`userDid:${key}`);
98
256
  },
257
+ // OAuth-based lookup for persistent user DID
258
+ getByOAuth: async (provider, subject) => {
259
+ try {
260
+ const oauthKey = STORAGE_KEYS.oauthIdentity(provider, subject);
261
+ const userDid = await this.env.DELEGATION_STORAGE.get(oauthKey, "text");
262
+ return userDid || null;
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ },
268
+ // OAuth-based storage for persistent user DID mapping
269
+ setByOAuth: async (provider, subject, did, ttl) => {
270
+ try {
271
+ const oauthKey = STORAGE_KEYS.oauthIdentity(provider, subject);
272
+ await this.env.DELEGATION_STORAGE.put(oauthKey, did, {
273
+ expirationTtl: ttl || 90 * 24 * 60 * 60, // Default 90 days for persistent mapping
274
+ });
275
+ }
276
+ catch (error) {
277
+ console.warn("[ConsentService] Failed to store OAuth mapping:", error);
278
+ throw error;
279
+ }
280
+ },
99
281
  },
100
282
  });
101
283
  }
102
- const userDid = await this.userDidManager.getOrCreateUserDid(sessionId);
284
+ const userDid = await this.userDidManager.getOrCreateUserDid(sessionId, oauthIdentity);
103
285
  // Cache in session storage
104
286
  try {
105
287
  const existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
@@ -169,27 +351,59 @@ export class ConsentService {
169
351
  * Creates the OAuth authorization URL with proper state parameter
170
352
  * for redirecting to OAuth provider.
171
353
  *
354
+ * ✅ CSRF Protection: If oauthSecurityService is provided, state is stored securely
355
+ * in KV storage and validated on callback. Otherwise, falls back to base64-encoded
356
+ * state (less secure, but backward compatible).
357
+ *
172
358
  * @param projectId - Project ID
173
359
  * @param agentDid - Agent DID
174
360
  * @param sessionId - Session ID
175
361
  * @param scopes - Requested scopes
176
362
  * @param serverUrl - Server URL for callback
363
+ * @param oauthSecurityService - Optional OAuthSecurityService for CSRF protection
177
364
  * @returns OAuth authorization URL
178
365
  */
179
- buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl) {
366
+ async buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl, oauthSecurityService) {
180
367
  const agentShieldUrl = this.env.AGENTSHIELD_API_URL || DEFAULT_AGENTSHIELD_URL;
181
368
  // Generate a temporary delegation ID for state (will be created after OAuth)
182
369
  const delegationId = `temp-${Date.now()}`;
183
- // Build state parameter with required fields
184
- const state = {
370
+ // Build state data with required fields
371
+ const stateData = {
185
372
  project_id: projectId,
186
373
  agent_did: agentDid,
187
374
  session_id: sessionId,
188
375
  delegation_id: delegationId,
189
376
  scopes: scopes,
377
+ storedAt: Date.now(),
190
378
  };
191
- // Encode state as base64 (Cloudflare Workers compatible)
192
- const stateParam = btoa(JSON.stringify(state));
379
+ let stateParam;
380
+ if (oauthSecurityService && this.env.DELEGATION_STORAGE) {
381
+ // ✅ CSRF Protection: Generate secure random state value and store state data securely
382
+ const randomBytes = crypto.getRandomValues(new Uint8Array(32));
383
+ const stateValue = btoa(String.fromCharCode(...randomBytes))
384
+ .replace(/\+/g, '-')
385
+ .replace(/\//g, '_')
386
+ .replace(/=/g, '');
387
+ // Store state data securely in KV (10 minute TTL for OAuth flow)
388
+ await oauthSecurityService.storeOAuthState(stateValue, stateData, 600);
389
+ stateParam = stateValue;
390
+ console.log('[ConsentService] 🔒 SECURITY EVENT: OAuth state stored securely:', {
391
+ projectId,
392
+ agentDid: agentDid.substring(0, 20) + '...',
393
+ sessionId: sessionId.substring(0, 20) + '...',
394
+ stateValue: stateValue.substring(0, 20) + '...',
395
+ timestamp: new Date().toISOString(),
396
+ eventType: 'oauth_state_stored',
397
+ ttl: 600
398
+ });
399
+ }
400
+ else {
401
+ // Fallback: Encode state as base64 (less secure, but backward compatible)
402
+ if (!oauthSecurityService) {
403
+ console.warn('[ConsentService] ⚠️ SECURITY WARNING: OAuthSecurityService not provided, using insecure state encoding');
404
+ }
405
+ stateParam = btoa(JSON.stringify(stateData));
406
+ }
193
407
  // Build OAuth authorization URL
194
408
  const oauthUrl = new URL(`${agentShieldUrl}/bouncer/oauth/authorize`);
195
409
  oauthUrl.searchParams.set("response_type", "code");
@@ -395,14 +609,44 @@ export class ConsentService {
395
609
  const oauthRequired = await this.isOAuthRequired(projectId, oauthIdentity);
396
610
  if (oauthRequired) {
397
611
  // OAuth is required - redirect to OAuth provider instead of showing consent page
398
- const oauthUrl = this.buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl);
399
- console.log("[ConsentService] OAuth required, redirecting to OAuth provider:", {
612
+ // Note: oauthSecurityService is optional - if not provided, falls back to insecure encoding
613
+ const oauthSecurityService = this.env.DELEGATION_STORAGE
614
+ ? new (await import('./oauth-security.service')).OAuthSecurityService(this.env.DELEGATION_STORAGE, this.env.OAUTH_ENCRYPTION_SECRET)
615
+ : undefined;
616
+ const oauthUrl = await this.buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl, oauthSecurityService);
617
+ console.log("[ConsentService] 🔒 SECURITY EVENT: OAuth required, redirecting to OAuth provider:", {
400
618
  projectId,
401
619
  agentDid: agentDid.substring(0, 20) + "...",
402
620
  oauthUrl: oauthUrl.substring(0, 100) + "...",
621
+ hasSecureState: !!oauthSecurityService,
622
+ timestamp: new Date().toISOString(),
623
+ eventType: 'oauth_redirect_initiated'
403
624
  });
404
625
  return Response.redirect(oauthUrl, 302);
405
626
  }
627
+ // ✅ Lazy initialization with projectId
628
+ const auditService = await this.getAuditService(projectId);
629
+ // Log page view event (if audit service available)
630
+ if (auditService) {
631
+ await auditService
632
+ .logConsentPageView({
633
+ sessionId,
634
+ agentDid,
635
+ targetTools: [tool], // Wrap in array
636
+ scopes,
637
+ projectId,
638
+ })
639
+ .catch((err) => {
640
+ // Structured error logging
641
+ console.error("[ConsentService] Audit logging failed", {
642
+ eventType: "consent:page_viewed",
643
+ sessionId,
644
+ error: err instanceof Error ? err.message : String(err),
645
+ stack: err instanceof Error ? err.stack : undefined,
646
+ });
647
+ // Don't throw - audit failures shouldn't break consent flow
648
+ });
649
+ }
406
650
  // Build consent page config
407
651
  const pageConfig = {
408
652
  tool,
@@ -439,6 +683,1079 @@ export class ConsentService {
439
683
  });
440
684
  }
441
685
  }
686
+ /**
687
+ * Parse request body from JSON or FormData
688
+ *
689
+ * Handles both JSON and FormData/multipart requests, converting
690
+ * FormData fields to the correct format for ConsentApprovalRequest.
691
+ *
692
+ * @param request - Request to parse
693
+ * @returns Parsed body object
694
+ */
695
+ async parseRequestBody(request) {
696
+ const contentType = request.headers.get("content-type") || "";
697
+ // Helper function to parse form fields into body object
698
+ const parseFormFields = (entries) => {
699
+ const body = {};
700
+ for (const [key, value] of entries) {
701
+ // Handle special fields that need parsing
702
+ if (key === "scopes" || key === "scopes[]") {
703
+ // Scopes come as JSON string
704
+ try {
705
+ body["scopes"] = JSON.parse(value);
706
+ }
707
+ catch {
708
+ // If not JSON, treat as single scope or array
709
+ body["scopes"] = value.includes(",") ? value.split(",") : [value];
710
+ }
711
+ }
712
+ else if (key === "oauth_identity" || key === "oauth_identity_json") {
713
+ // OAuth identity comes as JSON string
714
+ try {
715
+ const parsed = JSON.parse(value);
716
+ body["oauth_identity"] = parsed || null;
717
+ }
718
+ catch {
719
+ // Invalid JSON, set to null
720
+ body["oauth_identity"] = null;
721
+ }
722
+ }
723
+ else if (key === "termsAccepted") {
724
+ // Convert checkbox values to boolean
725
+ body[key] =
726
+ value === "on" ||
727
+ value === "true" ||
728
+ value === "1" ||
729
+ value === "yes";
730
+ }
731
+ else if (key === "customFields") {
732
+ // Custom fields come as JSON string
733
+ try {
734
+ body[key] = JSON.parse(value);
735
+ }
736
+ catch {
737
+ // If not JSON, skip
738
+ }
739
+ }
740
+ else {
741
+ // Regular string fields
742
+ body[key] = value;
743
+ }
744
+ }
745
+ // Default termsAccepted to true if not provided (common for checkboxes)
746
+ if (!("termsAccepted" in body)) {
747
+ body.termsAccepted = true;
748
+ }
749
+ // Default scopes to empty array if not provided (required by schema but can be empty)
750
+ if (!("scopes" in body)) {
751
+ body.scopes = [];
752
+ }
753
+ return body;
754
+ };
755
+ // Parse URL-encoded form data (application/x-www-form-urlencoded)
756
+ // When Content-Type is URL-encoded but FormData object is passed, try FormData parsing first
757
+ if (contentType.includes("application/x-www-form-urlencoded")) {
758
+ // Try FormData parsing first (FormData object might have been passed)
759
+ try {
760
+ const clonedRequest = request.clone();
761
+ const formData = await clonedRequest.formData();
762
+ const body = {};
763
+ let hasValidFields = false;
764
+ // Check if FormData parsing returned malformed data (entire multipart body as single entry)
765
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
766
+ const entriesArray = Array.from(formData.entries());
767
+ if (entriesArray.length === 1 &&
768
+ typeof entriesArray[0][0] === "string" &&
769
+ entriesArray[0][0].includes("Content-Disposition") &&
770
+ typeof entriesArray[0][1] === "string" &&
771
+ entriesArray[0][1].toString().includes("Content-Disposition")) {
772
+ // Manually parse multipart format from the value string
773
+ // The key contains the first boundary and Content-Disposition header start
774
+ // The value contains the rest of the first field and all subsequent fields
775
+ const keyPart = entriesArray[0][0];
776
+ const valuePart = entriesArray[0][1];
777
+ // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
778
+ let multipartBody;
779
+ if (keyPart.trim().endsWith("name") &&
780
+ valuePart.trim().startsWith('"')) {
781
+ // Key ends with "name", value starts with '"fieldname"', combine with = between them
782
+ multipartBody = keyPart + "=" + valuePart;
783
+ }
784
+ else {
785
+ multipartBody = keyPart + valuePart;
786
+ }
787
+ // Match: Content-Disposition header with field name, then capture value until next boundary or end
788
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
789
+ let match;
790
+ while ((match = fieldRegex.exec(multipartBody)) !== null) {
791
+ const fieldName = match[1];
792
+ const fieldValue = match[2].trim(); // Remove trailing newlines
793
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
794
+ try {
795
+ body["scopes"] = JSON.parse(fieldValue);
796
+ }
797
+ catch {
798
+ body["scopes"] = fieldValue.includes(",")
799
+ ? fieldValue.split(",")
800
+ : [fieldValue];
801
+ }
802
+ }
803
+ else if (fieldName === "oauth_identity" ||
804
+ fieldName === "oauth_identity_json") {
805
+ try {
806
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
807
+ }
808
+ catch {
809
+ body["oauth_identity"] = null;
810
+ }
811
+ }
812
+ else if (fieldName === "termsAccepted") {
813
+ body[fieldName] =
814
+ fieldValue === "on" ||
815
+ fieldValue === "true" ||
816
+ fieldValue === "1" ||
817
+ fieldValue === "yes";
818
+ }
819
+ else if (fieldName === "customFields") {
820
+ try {
821
+ body[fieldName] = JSON.parse(fieldValue);
822
+ }
823
+ catch {
824
+ // Skip
825
+ }
826
+ }
827
+ else {
828
+ body[fieldName] = fieldValue;
829
+ }
830
+ hasValidFields = true;
831
+ }
832
+ }
833
+ else {
834
+ // Normal FormData parsing
835
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
836
+ for (const [key, value] of formData.entries()) {
837
+ if (value instanceof File) {
838
+ continue;
839
+ }
840
+ // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
841
+ let fieldName = key;
842
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
843
+ // Extract field name from Content-Disposition header
844
+ const nameMatch = key.match(/name="([^"]+)"/);
845
+ if (nameMatch && nameMatch[1]) {
846
+ fieldName = nameMatch[1];
847
+ }
848
+ else {
849
+ // Skip if we can't extract a valid field name
850
+ continue;
851
+ }
852
+ }
853
+ else if (key.includes("------") ||
854
+ key.includes("\r\n") ||
855
+ key.trim() === "") {
856
+ // Skip boundary strings and invalid keys
857
+ continue;
858
+ }
859
+ hasValidFields = true;
860
+ const stringValue = value.toString();
861
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
862
+ try {
863
+ body["scopes"] = JSON.parse(stringValue);
864
+ }
865
+ catch {
866
+ body["scopes"] = stringValue.includes(",")
867
+ ? stringValue.split(",")
868
+ : [stringValue];
869
+ }
870
+ }
871
+ else if (fieldName === "oauth_identity" ||
872
+ fieldName === "oauth_identity_json") {
873
+ try {
874
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
875
+ }
876
+ catch {
877
+ body["oauth_identity"] = null;
878
+ }
879
+ }
880
+ else if (fieldName === "termsAccepted") {
881
+ body[fieldName] =
882
+ stringValue === "on" ||
883
+ stringValue === "true" ||
884
+ stringValue === "1" ||
885
+ stringValue === "yes";
886
+ }
887
+ else if (fieldName === "customFields") {
888
+ try {
889
+ body[fieldName] = JSON.parse(stringValue);
890
+ }
891
+ catch {
892
+ // Skip
893
+ }
894
+ }
895
+ else {
896
+ body[fieldName] = stringValue;
897
+ }
898
+ }
899
+ }
900
+ if (hasValidFields && Object.keys(body).length > 0) {
901
+ if (!("termsAccepted" in body)) {
902
+ body.termsAccepted = true;
903
+ }
904
+ // Default scopes to empty array if not provided
905
+ if (!("scopes" in body)) {
906
+ body.scopes = [];
907
+ }
908
+ return body;
909
+ }
910
+ }
911
+ catch (formDataError) {
912
+ // FormData parsing failed, try URL-encoded text parsing
913
+ console.warn("[ConsentService] FormData parsing failed, trying URL-encoded text:", formDataError);
914
+ }
915
+ // Try URL-encoded text parsing as fallback
916
+ try {
917
+ const text = await request.clone().text();
918
+ const params = new URLSearchParams(text);
919
+ // Type assertion: URLSearchParams.entries() exists at runtime
920
+ return parseFormFields(params.entries());
921
+ }
922
+ catch (urlEncodedError) {
923
+ // Both failed, fall through to JSON parsing
924
+ console.warn("[ConsentService] URL-encoded parsing also failed:", urlEncodedError);
925
+ }
926
+ }
927
+ // Parse multipart FormData (multipart/form-data or when FormData object is passed with other Content-Type)
928
+ // Include text/plain here to handle cases where FormData is passed with mismatched Content-Type
929
+ if (contentType.includes("multipart/form-data") ||
930
+ contentType.includes("form") ||
931
+ contentType === "" ||
932
+ contentType.includes("text/plain") ||
933
+ contentType.includes("text")) {
934
+ // Check if multipart/form-data Content-Type is missing boundary
935
+ // If so, try to parse as text first (FormData might have been serialized incorrectly)
936
+ if (contentType.includes("multipart/form-data") &&
937
+ !contentType.includes("boundary=")) {
938
+ try {
939
+ const textRequest = request.clone();
940
+ const text = await textRequest.text();
941
+ if (text && text.length > 0) {
942
+ // Try to manually parse multipart format from text
943
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
944
+ const body = {};
945
+ let match;
946
+ let hasValidFields = false;
947
+ while ((match = fieldRegex.exec(text)) !== null) {
948
+ const fieldName = match[1];
949
+ const fieldValue = match[2].trim();
950
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
951
+ try {
952
+ body["scopes"] = JSON.parse(fieldValue);
953
+ }
954
+ catch {
955
+ body["scopes"] = fieldValue.includes(",")
956
+ ? fieldValue.split(",")
957
+ : [fieldValue];
958
+ }
959
+ }
960
+ else if (fieldName === "oauth_identity" ||
961
+ fieldName === "oauth_identity_json") {
962
+ try {
963
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
964
+ }
965
+ catch {
966
+ body["oauth_identity"] = null;
967
+ }
968
+ }
969
+ else if (fieldName === "termsAccepted") {
970
+ body[fieldName] =
971
+ fieldValue === "on" ||
972
+ fieldValue === "true" ||
973
+ fieldValue === "1" ||
974
+ fieldValue === "yes";
975
+ }
976
+ else if (fieldName === "customFields") {
977
+ try {
978
+ body[fieldName] = JSON.parse(fieldValue);
979
+ }
980
+ catch {
981
+ // Skip
982
+ }
983
+ }
984
+ else {
985
+ body[fieldName] = fieldValue;
986
+ }
987
+ hasValidFields = true;
988
+ }
989
+ if (hasValidFields && Object.keys(body).length > 0) {
990
+ if (!("termsAccepted" in body)) {
991
+ body.termsAccepted = true;
992
+ }
993
+ // Default scopes to empty array if not provided
994
+ if (!("scopes" in body)) {
995
+ body.scopes = [];
996
+ }
997
+ return body;
998
+ }
999
+ }
1000
+ }
1001
+ catch {
1002
+ // If text parsing fails, fall through to FormData parsing
1003
+ }
1004
+ }
1005
+ try {
1006
+ const clonedRequest = request.clone();
1007
+ let formData;
1008
+ try {
1009
+ formData = await clonedRequest.formData();
1010
+ }
1011
+ catch (formDataError) {
1012
+ // Handle FormData parsing errors (missing boundary, wrong Content-Type, etc.)
1013
+ const errorMessage = formDataError instanceof Error
1014
+ ? formDataError.message
1015
+ : String(formDataError);
1016
+ const errorCause = formDataError instanceof Error && "cause" in formDataError
1017
+ ? formDataError.cause instanceof Error
1018
+ ? formDataError.cause.message
1019
+ : String(formDataError.cause)
1020
+ : "";
1021
+ if (errorMessage.includes("missing boundary") ||
1022
+ errorMessage.includes("boundary") ||
1023
+ errorMessage.includes("Content-Type was not one of") ||
1024
+ errorCause.includes("missing boundary") ||
1025
+ errorCause.includes("boundary")) {
1026
+ // When boundary is missing, try to parse as URL-encoded form data instead
1027
+ // This handles the case where FormData was passed but Content-Type doesn't include boundary
1028
+ try {
1029
+ const textRequest = request.clone();
1030
+ const text = await textRequest.text();
1031
+ // If text parsing succeeds, try URL-encoded parsing
1032
+ if (text && text.length > 0) {
1033
+ try {
1034
+ const params = new URLSearchParams(text);
1035
+ return parseFormFields(params.entries());
1036
+ }
1037
+ catch {
1038
+ // If URL-encoded parsing fails, the text might be multipart format
1039
+ // Try to manually parse multipart format from text
1040
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1041
+ const body = {};
1042
+ let match;
1043
+ let hasValidFields = false;
1044
+ while ((match = fieldRegex.exec(text)) !== null) {
1045
+ const fieldName = match[1];
1046
+ const fieldValue = match[2].trim();
1047
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1048
+ try {
1049
+ body["scopes"] = JSON.parse(fieldValue);
1050
+ }
1051
+ catch {
1052
+ body["scopes"] = fieldValue.includes(",")
1053
+ ? fieldValue.split(",")
1054
+ : [fieldValue];
1055
+ }
1056
+ }
1057
+ else if (fieldName === "oauth_identity" ||
1058
+ fieldName === "oauth_identity_json") {
1059
+ try {
1060
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1061
+ }
1062
+ catch {
1063
+ body["oauth_identity"] = null;
1064
+ }
1065
+ }
1066
+ else if (fieldName === "termsAccepted") {
1067
+ body[fieldName] =
1068
+ fieldValue === "on" ||
1069
+ fieldValue === "true" ||
1070
+ fieldValue === "1" ||
1071
+ fieldValue === "yes";
1072
+ }
1073
+ else if (fieldName === "customFields") {
1074
+ try {
1075
+ body[fieldName] = JSON.parse(fieldValue);
1076
+ }
1077
+ catch {
1078
+ // Skip
1079
+ }
1080
+ }
1081
+ else {
1082
+ body[fieldName] = fieldValue;
1083
+ }
1084
+ hasValidFields = true;
1085
+ }
1086
+ if (hasValidFields && Object.keys(body).length > 0) {
1087
+ if (!("termsAccepted" in body)) {
1088
+ body.termsAccepted = true;
1089
+ }
1090
+ // Default scopes to empty array if not provided
1091
+ if (!("scopes" in body)) {
1092
+ body.scopes = [];
1093
+ }
1094
+ return body;
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ catch {
1100
+ // If all parsing attempts fail, rethrow the original error
1101
+ }
1102
+ }
1103
+ throw formDataError;
1104
+ }
1105
+ const body = {};
1106
+ let hasValidFields = false;
1107
+ // Check if FormData parsing returned malformed data (entire multipart body as single entry)
1108
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1109
+ const entriesArray = Array.from(formData.entries());
1110
+ if (entriesArray.length === 1 &&
1111
+ typeof entriesArray[0][0] === "string" &&
1112
+ entriesArray[0][0].includes("Content-Disposition") &&
1113
+ typeof entriesArray[0][1] === "string" &&
1114
+ entriesArray[0][1].toString().includes("Content-Disposition")) {
1115
+ // Manually parse multipart format from the value string
1116
+ const keyPart = entriesArray[0][0];
1117
+ const valuePart = entriesArray[0][1];
1118
+ // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
1119
+ let multipartBody;
1120
+ if (keyPart.trim().endsWith("name") &&
1121
+ valuePart.trim().startsWith('"')) {
1122
+ multipartBody = keyPart + "=" + valuePart;
1123
+ }
1124
+ else {
1125
+ multipartBody = keyPart + valuePart;
1126
+ }
1127
+ // Match: Content-Disposition header with field name, then capture value until next boundary or end
1128
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1129
+ let match;
1130
+ while ((match = fieldRegex.exec(multipartBody)) !== null) {
1131
+ const fieldName = match[1];
1132
+ const fieldValue = match[2].trim();
1133
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1134
+ try {
1135
+ body["scopes"] = JSON.parse(fieldValue);
1136
+ }
1137
+ catch {
1138
+ body["scopes"] = fieldValue.includes(",")
1139
+ ? fieldValue.split(",")
1140
+ : [fieldValue];
1141
+ }
1142
+ }
1143
+ else if (fieldName === "oauth_identity" ||
1144
+ fieldName === "oauth_identity_json") {
1145
+ try {
1146
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1147
+ }
1148
+ catch {
1149
+ body["oauth_identity"] = null;
1150
+ }
1151
+ }
1152
+ else if (fieldName === "termsAccepted") {
1153
+ body[fieldName] =
1154
+ fieldValue === "on" ||
1155
+ fieldValue === "true" ||
1156
+ fieldValue === "1" ||
1157
+ fieldValue === "yes";
1158
+ }
1159
+ else if (fieldName === "customFields") {
1160
+ try {
1161
+ body[fieldName] = JSON.parse(fieldValue);
1162
+ }
1163
+ catch {
1164
+ // Skip
1165
+ }
1166
+ }
1167
+ else {
1168
+ body[fieldName] = fieldValue;
1169
+ }
1170
+ hasValidFields = true;
1171
+ }
1172
+ }
1173
+ else {
1174
+ // Normal FormData parsing
1175
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1176
+ for (const [key, value] of formData.entries()) {
1177
+ if (value instanceof File) {
1178
+ continue;
1179
+ }
1180
+ // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
1181
+ let fieldName = key;
1182
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
1183
+ // Extract field name from Content-Disposition header
1184
+ const nameMatch = key.match(/name="([^"]+)"/);
1185
+ if (nameMatch && nameMatch[1]) {
1186
+ fieldName = nameMatch[1];
1187
+ }
1188
+ else {
1189
+ // Skip if we can't extract a valid field name
1190
+ continue;
1191
+ }
1192
+ }
1193
+ else if (key.includes("------") ||
1194
+ key.includes("\r\n") ||
1195
+ key.trim() === "") {
1196
+ // Skip boundary strings and invalid keys
1197
+ continue;
1198
+ }
1199
+ hasValidFields = true;
1200
+ const stringValue = value.toString();
1201
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1202
+ try {
1203
+ body["scopes"] = JSON.parse(stringValue);
1204
+ }
1205
+ catch {
1206
+ body["scopes"] = stringValue.includes(",")
1207
+ ? stringValue.split(",")
1208
+ : [stringValue];
1209
+ }
1210
+ }
1211
+ else if (fieldName === "oauth_identity" ||
1212
+ fieldName === "oauth_identity_json") {
1213
+ try {
1214
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
1215
+ }
1216
+ catch {
1217
+ body["oauth_identity"] = null;
1218
+ }
1219
+ }
1220
+ else if (fieldName === "termsAccepted") {
1221
+ body[fieldName] =
1222
+ stringValue === "on" ||
1223
+ stringValue === "true" ||
1224
+ stringValue === "1" ||
1225
+ stringValue === "yes";
1226
+ }
1227
+ else if (fieldName === "customFields") {
1228
+ try {
1229
+ body[fieldName] = JSON.parse(stringValue);
1230
+ }
1231
+ catch {
1232
+ // Skip
1233
+ }
1234
+ }
1235
+ else {
1236
+ body[fieldName] = stringValue;
1237
+ }
1238
+ }
1239
+ }
1240
+ if (hasValidFields && Object.keys(body).length > 0) {
1241
+ if (!("termsAccepted" in body)) {
1242
+ body.termsAccepted = true;
1243
+ }
1244
+ // Default scopes to empty array if not provided
1245
+ if (!("scopes" in body)) {
1246
+ body.scopes = [];
1247
+ }
1248
+ return body;
1249
+ }
1250
+ }
1251
+ catch (formDataError) {
1252
+ console.warn("[ConsentService] FormData parsing failed:", formDataError);
1253
+ // Fall through to JSON parsing
1254
+ }
1255
+ }
1256
+ // Default to JSON parsing
1257
+ try {
1258
+ return await request.json();
1259
+ }
1260
+ catch (error) {
1261
+ // If JSON parsing fails and content-type suggests form data, try FormData parsing as fallback
1262
+ // Special handling for text/plain with FormData body - try text parsing first
1263
+ if (contentType === "text/plain") {
1264
+ try {
1265
+ const textRequest = request.clone();
1266
+ const text = await textRequest.text();
1267
+ if (text && text.length > 0) {
1268
+ // Try URL-encoded parsing first
1269
+ try {
1270
+ const params = new URLSearchParams(text);
1271
+ const body = parseFormFields(params.entries());
1272
+ if (Object.keys(body).length > 0) {
1273
+ return body;
1274
+ }
1275
+ }
1276
+ catch {
1277
+ // URL-encoded parsing failed, try multipart format
1278
+ }
1279
+ // Try multipart format parsing (FormData serializes to multipart)
1280
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1281
+ const body = {};
1282
+ let match;
1283
+ let hasValidFields = false;
1284
+ while ((match = fieldRegex.exec(text)) !== null) {
1285
+ const fieldName = match[1];
1286
+ const fieldValue = match[2].trim();
1287
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1288
+ try {
1289
+ body["scopes"] = JSON.parse(fieldValue);
1290
+ }
1291
+ catch {
1292
+ body["scopes"] = fieldValue.includes(",")
1293
+ ? fieldValue.split(",")
1294
+ : [fieldValue];
1295
+ }
1296
+ }
1297
+ else if (fieldName === "oauth_identity" ||
1298
+ fieldName === "oauth_identity_json") {
1299
+ try {
1300
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1301
+ }
1302
+ catch {
1303
+ body["oauth_identity"] = null;
1304
+ }
1305
+ }
1306
+ else if (fieldName === "termsAccepted") {
1307
+ body[fieldName] =
1308
+ fieldValue === "on" ||
1309
+ fieldValue === "true" ||
1310
+ fieldValue === "1" ||
1311
+ fieldValue === "yes";
1312
+ }
1313
+ else if (fieldName === "customFields") {
1314
+ try {
1315
+ body[fieldName] = JSON.parse(fieldValue);
1316
+ }
1317
+ catch {
1318
+ // Skip
1319
+ }
1320
+ }
1321
+ else {
1322
+ body[fieldName] = fieldValue;
1323
+ }
1324
+ hasValidFields = true;
1325
+ }
1326
+ if (hasValidFields && Object.keys(body).length > 0) {
1327
+ if (!("termsAccepted" in body)) {
1328
+ body.termsAccepted = true;
1329
+ }
1330
+ // Default scopes to empty array if not provided
1331
+ if (!("scopes" in body)) {
1332
+ body.scopes = [];
1333
+ }
1334
+ return body;
1335
+ }
1336
+ }
1337
+ }
1338
+ catch {
1339
+ // Text parsing failed, fall through to FormData parsing
1340
+ }
1341
+ }
1342
+ if (!contentType.includes("application/json") &&
1343
+ (contentType.includes("form") ||
1344
+ contentType === "" ||
1345
+ contentType.includes("text"))) {
1346
+ try {
1347
+ // Clone request ONCE before trying any parsing, so we can reuse it if FormData parsing fails
1348
+ const fallbackRequest = request.clone();
1349
+ // Try FormData parsing as fallback (even if Content-Type doesn't match)
1350
+ let formData;
1351
+ try {
1352
+ formData = await fallbackRequest.formData();
1353
+ }
1354
+ catch (formDataParseError) {
1355
+ // If FormData parsing fails, try reading as text and parsing manually
1356
+ // This handles cases where Content-Type doesn't match (e.g., text/plain with FormData body)
1357
+ try {
1358
+ // Use a fresh clone for text parsing (body might be consumed by failed FormData attempt)
1359
+ const textRequest = request.clone();
1360
+ const text = await textRequest.text();
1361
+ if (text && text.length > 0) {
1362
+ // First try URL-encoded parsing (simpler format)
1363
+ try {
1364
+ const params = new URLSearchParams(text);
1365
+ const body = parseFormFields(params.entries());
1366
+ if (Object.keys(body).length > 0) {
1367
+ return body;
1368
+ }
1369
+ }
1370
+ catch {
1371
+ // URL-encoded parsing failed, try multipart format
1372
+ }
1373
+ // Try to manually parse multipart format from text
1374
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1375
+ const body = {};
1376
+ let match;
1377
+ let hasValidFields = false;
1378
+ while ((match = fieldRegex.exec(text)) !== null) {
1379
+ const fieldName = match[1];
1380
+ const fieldValue = match[2].trim();
1381
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1382
+ try {
1383
+ body["scopes"] = JSON.parse(fieldValue);
1384
+ }
1385
+ catch {
1386
+ body["scopes"] = fieldValue.includes(",")
1387
+ ? fieldValue.split(",")
1388
+ : [fieldValue];
1389
+ }
1390
+ }
1391
+ else if (fieldName === "oauth_identity" ||
1392
+ fieldName === "oauth_identity_json") {
1393
+ try {
1394
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1395
+ }
1396
+ catch {
1397
+ body["oauth_identity"] = null;
1398
+ }
1399
+ }
1400
+ else if (fieldName === "termsAccepted") {
1401
+ body[fieldName] =
1402
+ fieldValue === "on" ||
1403
+ fieldValue === "true" ||
1404
+ fieldValue === "1" ||
1405
+ fieldValue === "yes";
1406
+ }
1407
+ else if (fieldName === "customFields") {
1408
+ try {
1409
+ body[fieldName] = JSON.parse(fieldValue);
1410
+ }
1411
+ catch {
1412
+ // Skip
1413
+ }
1414
+ }
1415
+ else {
1416
+ body[fieldName] = fieldValue;
1417
+ }
1418
+ hasValidFields = true;
1419
+ }
1420
+ if (hasValidFields && Object.keys(body).length > 0) {
1421
+ if (!("termsAccepted" in body)) {
1422
+ body.termsAccepted = true;
1423
+ }
1424
+ // Default scopes to empty array if not provided
1425
+ if (!("scopes" in body)) {
1426
+ body.scopes = [];
1427
+ }
1428
+ return body;
1429
+ }
1430
+ }
1431
+ }
1432
+ catch {
1433
+ // If text parsing also fails, rethrow original FormData error
1434
+ }
1435
+ throw formDataParseError;
1436
+ }
1437
+ // Check if FormData has valid entries (might be empty if Content-Type mismatch)
1438
+ const entriesArray = Array.from(formData.entries());
1439
+ if (entriesArray.length === 0) {
1440
+ // FormData parsing succeeded but returned no entries - try text parsing
1441
+ // This handles cases where Content-Type doesn't match (e.g., text/plain with FormData body)
1442
+ try {
1443
+ const textRequest = request.clone();
1444
+ const text = await textRequest.text();
1445
+ if (text && text.length > 0) {
1446
+ // Try URL-encoded parsing first
1447
+ try {
1448
+ const params = new URLSearchParams(text);
1449
+ const body = parseFormFields(params.entries());
1450
+ if (Object.keys(body).length > 0) {
1451
+ return body;
1452
+ }
1453
+ }
1454
+ catch {
1455
+ // URL-encoded parsing failed, try multipart format
1456
+ }
1457
+ // Try multipart format parsing
1458
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1459
+ const body = {};
1460
+ let match;
1461
+ let hasValidFields = false;
1462
+ while ((match = fieldRegex.exec(text)) !== null) {
1463
+ const fieldName = match[1];
1464
+ const fieldValue = match[2].trim();
1465
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1466
+ try {
1467
+ body["scopes"] = JSON.parse(fieldValue);
1468
+ }
1469
+ catch {
1470
+ body["scopes"] = fieldValue.includes(",")
1471
+ ? fieldValue.split(",")
1472
+ : [fieldValue];
1473
+ }
1474
+ }
1475
+ else if (fieldName === "oauth_identity" ||
1476
+ fieldName === "oauth_identity_json") {
1477
+ try {
1478
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1479
+ }
1480
+ catch {
1481
+ body["oauth_identity"] = null;
1482
+ }
1483
+ }
1484
+ else if (fieldName === "termsAccepted") {
1485
+ body[fieldName] =
1486
+ fieldValue === "on" ||
1487
+ fieldValue === "true" ||
1488
+ fieldValue === "1" ||
1489
+ fieldValue === "yes";
1490
+ }
1491
+ else if (fieldName === "customFields") {
1492
+ try {
1493
+ body[fieldName] = JSON.parse(fieldValue);
1494
+ }
1495
+ catch {
1496
+ // Skip
1497
+ }
1498
+ }
1499
+ else {
1500
+ body[fieldName] = fieldValue;
1501
+ }
1502
+ hasValidFields = true;
1503
+ }
1504
+ if (hasValidFields && Object.keys(body).length > 0) {
1505
+ if (!("termsAccepted" in body)) {
1506
+ body.termsAccepted = true;
1507
+ }
1508
+ // Default scopes to empty array if not provided
1509
+ if (!("scopes" in body)) {
1510
+ body.scopes = [];
1511
+ }
1512
+ return body;
1513
+ }
1514
+ }
1515
+ // If text parsing failed or found no valid fields, throw error to trigger fallback
1516
+ throw new Error("FormData parsing returned empty entries and text parsing found no valid fields");
1517
+ }
1518
+ catch (textParseError) {
1519
+ // Text parsing failed or found no valid fields - throw error to trigger JSON fallback
1520
+ // This ensures we don't continue with empty FormData entries
1521
+ throw new Error(`Failed to parse FormData: ${textParseError instanceof Error ? textParseError.message : "Unknown error"}`);
1522
+ }
1523
+ }
1524
+ const body = {};
1525
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1526
+ let hasValidEntries = false;
1527
+ for (const [key, value] of formData.entries()) {
1528
+ if (value instanceof File)
1529
+ continue;
1530
+ // Extract field name from malformed keys
1531
+ let fieldName = key;
1532
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
1533
+ const nameMatch = key.match(/name="([^"]+)"/);
1534
+ if (nameMatch && nameMatch[1]) {
1535
+ fieldName = nameMatch[1];
1536
+ }
1537
+ else {
1538
+ continue;
1539
+ }
1540
+ }
1541
+ else if (key.includes("------") ||
1542
+ key.includes("\r\n") ||
1543
+ key.trim() === "" ||
1544
+ key.includes("formdata-undici")) {
1545
+ // Malformed key - likely from text/plain Content-Type mismatch
1546
+ // Skip this entry and fall back to text parsing
1547
+ continue;
1548
+ }
1549
+ // If we have a valid field name, mark as having valid entries
1550
+ if (fieldName && fieldName !== key) {
1551
+ hasValidEntries = true;
1552
+ }
1553
+ else if (fieldName &&
1554
+ !key.includes("Content-Disposition") &&
1555
+ !key.includes("------") &&
1556
+ !key.includes("\r\n")) {
1557
+ hasValidEntries = true;
1558
+ }
1559
+ // Process the field value
1560
+ const stringValue = value.toString();
1561
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1562
+ try {
1563
+ body["scopes"] = JSON.parse(stringValue);
1564
+ }
1565
+ catch {
1566
+ body["scopes"] = stringValue.includes(",")
1567
+ ? stringValue.split(",")
1568
+ : [stringValue];
1569
+ }
1570
+ }
1571
+ else if (fieldName === "oauth_identity" ||
1572
+ fieldName === "oauth_identity_json") {
1573
+ try {
1574
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
1575
+ }
1576
+ catch {
1577
+ body["oauth_identity"] = null;
1578
+ }
1579
+ }
1580
+ else if (fieldName === "termsAccepted") {
1581
+ body[fieldName] =
1582
+ stringValue === "on" ||
1583
+ stringValue === "true" ||
1584
+ stringValue === "1" ||
1585
+ stringValue === "yes";
1586
+ }
1587
+ else if (fieldName === "customFields") {
1588
+ try {
1589
+ body[fieldName] = JSON.parse(stringValue);
1590
+ }
1591
+ catch {
1592
+ // Skip
1593
+ }
1594
+ }
1595
+ else {
1596
+ body[fieldName] = stringValue;
1597
+ }
1598
+ }
1599
+ // If we didn't get any valid entries (all keys were malformed), try text parsing
1600
+ if (!hasValidEntries || Object.keys(body).length === 0) {
1601
+ // Try text parsing as fallback when FormData parsing returns no valid entries
1602
+ try {
1603
+ const textRequest = request.clone();
1604
+ const text = await textRequest.text();
1605
+ if (text && text.length > 0) {
1606
+ // Try multipart format parsing from text
1607
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1608
+ const textBody = {};
1609
+ let match;
1610
+ let hasValidFields = false;
1611
+ while ((match = fieldRegex.exec(text)) !== null) {
1612
+ const fieldName = match[1];
1613
+ const fieldValue = match[2].trim();
1614
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1615
+ try {
1616
+ textBody["scopes"] = JSON.parse(fieldValue);
1617
+ }
1618
+ catch {
1619
+ textBody["scopes"] = fieldValue.includes(",")
1620
+ ? fieldValue.split(",")
1621
+ : [fieldValue];
1622
+ }
1623
+ }
1624
+ else if (fieldName === "oauth_identity" ||
1625
+ fieldName === "oauth_identity_json") {
1626
+ try {
1627
+ textBody["oauth_identity"] =
1628
+ JSON.parse(fieldValue) || null;
1629
+ }
1630
+ catch {
1631
+ textBody["oauth_identity"] = null;
1632
+ }
1633
+ }
1634
+ else if (fieldName === "termsAccepted") {
1635
+ textBody[fieldName] =
1636
+ fieldValue === "on" ||
1637
+ fieldValue === "true" ||
1638
+ fieldValue === "1" ||
1639
+ fieldValue === "yes";
1640
+ }
1641
+ else if (fieldName === "customFields") {
1642
+ try {
1643
+ textBody[fieldName] = JSON.parse(fieldValue);
1644
+ }
1645
+ catch {
1646
+ // Skip
1647
+ }
1648
+ }
1649
+ else {
1650
+ textBody[fieldName] = fieldValue;
1651
+ }
1652
+ hasValidFields = true;
1653
+ }
1654
+ if (hasValidFields && Object.keys(textBody).length > 0) {
1655
+ if (!("termsAccepted" in textBody)) {
1656
+ textBody.termsAccepted = true;
1657
+ }
1658
+ if (!("scopes" in textBody)) {
1659
+ textBody.scopes = [];
1660
+ }
1661
+ return textBody;
1662
+ }
1663
+ }
1664
+ }
1665
+ catch {
1666
+ // Text parsing also failed, fall through to throw error
1667
+ }
1668
+ throw new Error("FormData parsing returned only malformed entries - falling back to text parsing");
1669
+ }
1670
+ if (!("termsAccepted" in body)) {
1671
+ body.termsAccepted = true;
1672
+ }
1673
+ // Default scopes to empty array if not provided
1674
+ if (!("scopes" in body)) {
1675
+ body.scopes = [];
1676
+ }
1677
+ return body;
1678
+ }
1679
+ catch (formError) {
1680
+ // FormData parsing failed - if it was due to malformed entries, try text parsing
1681
+ const errorMessage = formError instanceof Error ? formError.message : String(formError);
1682
+ if (errorMessage.includes("malformed entries") ||
1683
+ errorMessage.includes("empty entries") ||
1684
+ errorMessage.includes("falling back to text parsing")) {
1685
+ // Try text parsing as last resort
1686
+ try {
1687
+ const textRequest = request.clone();
1688
+ const text = await textRequest.text();
1689
+ if (text && text.length > 0) {
1690
+ // Try multipart format parsing
1691
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1692
+ const body = {};
1693
+ let match;
1694
+ let hasValidFields = false;
1695
+ while ((match = fieldRegex.exec(text)) !== null) {
1696
+ const fieldName = match[1];
1697
+ const fieldValue = match[2].trim();
1698
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1699
+ try {
1700
+ body["scopes"] = JSON.parse(fieldValue);
1701
+ }
1702
+ catch {
1703
+ body["scopes"] = fieldValue.includes(",")
1704
+ ? fieldValue.split(",")
1705
+ : [fieldValue];
1706
+ }
1707
+ }
1708
+ else if (fieldName === "oauth_identity" ||
1709
+ fieldName === "oauth_identity_json") {
1710
+ try {
1711
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1712
+ }
1713
+ catch {
1714
+ body["oauth_identity"] = null;
1715
+ }
1716
+ }
1717
+ else if (fieldName === "termsAccepted") {
1718
+ body[fieldName] =
1719
+ fieldValue === "on" ||
1720
+ fieldValue === "true" ||
1721
+ fieldValue === "1" ||
1722
+ fieldValue === "yes";
1723
+ }
1724
+ else if (fieldName === "customFields") {
1725
+ try {
1726
+ body[fieldName] = JSON.parse(fieldValue);
1727
+ }
1728
+ catch {
1729
+ // Skip
1730
+ }
1731
+ }
1732
+ else {
1733
+ body[fieldName] = fieldValue;
1734
+ }
1735
+ hasValidFields = true;
1736
+ }
1737
+ if (hasValidFields && Object.keys(body).length > 0) {
1738
+ if (!("termsAccepted" in body)) {
1739
+ body.termsAccepted = true;
1740
+ }
1741
+ // Default scopes to empty array if not provided
1742
+ if (!("scopes" in body)) {
1743
+ body.scopes = [];
1744
+ }
1745
+ return body;
1746
+ }
1747
+ }
1748
+ }
1749
+ catch {
1750
+ // Text parsing also failed
1751
+ }
1752
+ }
1753
+ // Both failed, throw original error
1754
+ }
1755
+ }
1756
+ throw new Error(`Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`);
1757
+ }
1758
+ }
442
1759
  /**
443
1760
  * Handle consent approval
444
1761
  *
@@ -449,11 +1766,33 @@ export class ConsentService {
449
1766
  * @returns JSON response
450
1767
  */
451
1768
  async handleApproval(request) {
1769
+ console.log("[ConsentService] Approval request received");
452
1770
  try {
453
- // Parse and validate request body
454
- const body = await request.json();
1771
+ // Parse and validate request body (supports both JSON and FormData)
1772
+ const body = await this.parseRequestBody(request);
1773
+ console.log("[ConsentService] Request body parsed:", {
1774
+ hasBody: !!body,
1775
+ bodyKeys: Object.keys(body || {}),
1776
+ hasOAuthIdentity: !!body?.oauth_identity,
1777
+ });
1778
+ // Convert null oauth_identity to undefined for proper schema validation
1779
+ // Zod's .nullish() should handle null, but converting to undefined is more explicit
1780
+ // and avoids potential edge cases with FormData parsing
1781
+ if (body &&
1782
+ typeof body === "object" &&
1783
+ body !== null &&
1784
+ "oauth_identity" in body) {
1785
+ const bodyObj = body;
1786
+ if (bodyObj.oauth_identity === null) {
1787
+ bodyObj.oauth_identity = undefined;
1788
+ }
1789
+ }
455
1790
  const validation = validateConsentApprovalRequest(body);
456
1791
  if (!validation.success) {
1792
+ console.error("[ConsentService] Approval request validation failed:", {
1793
+ errors: validation.error.errors,
1794
+ receivedBody: body,
1795
+ });
457
1796
  return new Response(JSON.stringify({
458
1797
  success: false,
459
1798
  error: "Invalid request",
@@ -465,6 +1804,13 @@ export class ConsentService {
465
1804
  });
466
1805
  }
467
1806
  const approvalRequest = validation.data;
1807
+ console.log("[ConsentService] Approval request validated:", {
1808
+ agentDid: approvalRequest.agent_did?.substring(0, 20) + "...",
1809
+ sessionId: approvalRequest.session_id?.substring(0, 20) + "...",
1810
+ scopes: approvalRequest.scopes,
1811
+ hasOAuthIdentity: !!approvalRequest.oauth_identity,
1812
+ oauthProvider: approvalRequest.oauth_identity?.provider,
1813
+ });
468
1814
  // Validate terms acceptance if required
469
1815
  const consentConfig = await this.configService.getConsentConfig(approvalRequest.project_id);
470
1816
  if (consentConfig.terms?.required && !approvalRequest.termsAccepted) {
@@ -477,9 +1823,40 @@ export class ConsentService {
477
1823
  headers: { "Content-Type": "application/json" },
478
1824
  });
479
1825
  }
1826
+ // ✅ Extract projectId from approval request
1827
+ const projectId = approvalRequest.project_id;
1828
+ // ✅ Lazy initialization with projectId
1829
+ const auditService = await this.getAuditService(projectId);
1830
+ // Check if user needs credentials before delegation
1831
+ const needsCredentials = !approvalRequest.user_did && !approvalRequest.oauth_identity;
1832
+ if (needsCredentials && auditService) {
1833
+ await auditService
1834
+ .logCredentialRequired({
1835
+ sessionId: approvalRequest.session_id,
1836
+ agentDid: approvalRequest.agent_did,
1837
+ targetTools: [approvalRequest.tool], // Array
1838
+ scopes: approvalRequest.scopes,
1839
+ projectId,
1840
+ oauthProvider: approvalRequest.oauth_identity?.provider,
1841
+ })
1842
+ .catch((err) => {
1843
+ console.error("[ConsentService] Failed to log credential required", {
1844
+ eventType: "consent:credential_required",
1845
+ sessionId: approvalRequest.session_id,
1846
+ error: err instanceof Error ? err.message : String(err),
1847
+ });
1848
+ });
1849
+ // Note: We don't redirect here - the consent flow continues
1850
+ // The credential_required event is just for audit tracking
1851
+ }
480
1852
  // Create delegation via AgentShield API
1853
+ console.log("[ConsentService] Creating delegation...");
481
1854
  const delegationResult = await this.createDelegation(approvalRequest);
482
1855
  if (!delegationResult.success) {
1856
+ console.error("[ConsentService] Delegation creation failed:", {
1857
+ error: delegationResult.error,
1858
+ error_code: delegationResult.error_code,
1859
+ });
483
1860
  return new Response(JSON.stringify({
484
1861
  success: false,
485
1862
  error: delegationResult.error || "Failed to create delegation",
@@ -489,8 +1866,60 @@ export class ConsentService {
489
1866
  headers: { "Content-Type": "application/json" },
490
1867
  });
491
1868
  }
1869
+ console.log("[ConsentService] ✅ Delegation created successfully:", {
1870
+ delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
1871
+ });
492
1872
  // Store delegation token in KV
493
1873
  await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id);
1874
+ // ✅ After successful delegation creation - log audit events
1875
+ if (auditService && delegationResult.success) {
1876
+ try {
1877
+ // Get userDid (may have been generated during delegation creation)
1878
+ // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
1879
+ let userDid;
1880
+ if (approvalRequest.session_id) {
1881
+ try {
1882
+ userDid = await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined);
1883
+ }
1884
+ catch (error) {
1885
+ console.warn("[ConsentService] Failed to get userDid for audit logging:", error);
1886
+ // Continue without userDid - audit events can still be logged
1887
+ }
1888
+ }
1889
+ await auditService.logConsentApproval({
1890
+ sessionId: approvalRequest.session_id,
1891
+ userDid,
1892
+ agentDid: approvalRequest.agent_did,
1893
+ targetTools: [approvalRequest.tool], // Array
1894
+ scopes: approvalRequest.scopes,
1895
+ delegationId: delegationResult.delegation_id,
1896
+ projectId,
1897
+ termsAccepted: approvalRequest.termsAccepted || false,
1898
+ oauthIdentity: approvalRequest.oauth_identity
1899
+ ? {
1900
+ provider: approvalRequest.oauth_identity.provider,
1901
+ identifier: approvalRequest.oauth_identity.subject,
1902
+ }
1903
+ : undefined,
1904
+ });
1905
+ await auditService.logDelegationCreated({
1906
+ sessionId: approvalRequest.session_id,
1907
+ delegationId: delegationResult.delegation_id,
1908
+ agentDid: approvalRequest.agent_did,
1909
+ userDid,
1910
+ targetTools: [approvalRequest.tool], // Array
1911
+ scopes: approvalRequest.scopes,
1912
+ projectId,
1913
+ });
1914
+ }
1915
+ catch (error) {
1916
+ console.error("[ConsentService] Audit failed but continuing", {
1917
+ sessionId: approvalRequest.session_id,
1918
+ error: error instanceof Error ? error.message : String(error),
1919
+ eventTypes: ["consent:approved", "consent:delegation_created"],
1920
+ });
1921
+ }
1922
+ }
494
1923
  // Return success response
495
1924
  const response = {
496
1925
  success: true,
@@ -537,17 +1966,36 @@ export class ConsentService {
537
1966
  const fieldName = await getDelegationFieldName(this.env.DELEGATION_STORAGE);
538
1967
  // Get userDID from session or generate new ephemeral DID
539
1968
  // Phase 4 PR #3: Use OAuth identity if provided in approval request
1969
+ // CRITICAL: Must work with or without OAuth identity
1970
+ // CRITICAL: Always generate ephemeral DID if session_id is available, even without DELEGATION_STORAGE
540
1971
  let userDid;
541
- if (this.env.DELEGATION_STORAGE && request.session_id) {
1972
+ if (request.session_id) {
542
1973
  try {
543
- // Pass OAuth identity if available in approval request
544
- userDid = await this.getUserDidForSession(request.session_id, request.oauth_identity);
1974
+ console.log("[ConsentService] Getting User DID for session:", {
1975
+ sessionId: request.session_id.substring(0, 20) + "...",
1976
+ hasOAuthIdentity: !!request.oauth_identity,
1977
+ oauthProvider: request.oauth_identity?.provider,
1978
+ hasStorage: !!this.env.DELEGATION_STORAGE,
1979
+ });
1980
+ // Pass OAuth identity if available in approval request (can be null/undefined)
1981
+ // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
1982
+ userDid = await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
1983
+ );
1984
+ console.log("[ConsentService] User DID retrieved:", {
1985
+ userDid: userDid?.substring(0, 20) + "...",
1986
+ hasUserDid: !!userDid,
1987
+ });
545
1988
  }
546
1989
  catch (error) {
547
- console.warn("[ConsentService] Failed to get/generate userDid:", error);
548
- // Continue without userDid - delegation will use ephemeral placeholder
1990
+ console.error("[ConsentService] Failed to get/generate userDid:", error);
1991
+ // Continue without userDid - delegation will work without user_identifier
1992
+ // This is valid for non-OAuth scenarios, but we should log this as a warning
1993
+ console.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
549
1994
  }
550
1995
  }
1996
+ else {
1997
+ console.log("[ConsentService] No session_id provided - skipping User DID generation");
1998
+ }
551
1999
  const expiresInDays = 7; // Default to 7 days
552
2000
  // Build delegation request with error-based format detection
553
2001
  // Try full format first, fallback to simplified format on error
@@ -799,13 +2247,15 @@ export class ConsentService {
799
2247
  */
800
2248
  async buildFullFormatRequest(request, userDid, expiresInDays) {
801
2249
  const notAfter = Date.now() + expiresInDays * 24 * 60 * 60 * 1000;
2250
+ // Defensive check: ensure scopes is always defined (should be guaranteed by validation)
2251
+ const scopes = request.scopes ?? [];
802
2252
  return {
803
2253
  delegation: {
804
2254
  id: crypto.randomUUID(),
805
2255
  issuerDid: userDid || "did:key:z6MkEphemeral", // Use ephemeral if no userDid
806
2256
  subjectDid: request.agent_did,
807
2257
  constraints: {
808
- scopes: request.scopes,
2258
+ scopes,
809
2259
  notAfter,
810
2260
  notBefore: Date.now(),
811
2261
  },
@@ -840,18 +2290,28 @@ export class ConsentService {
840
2290
  */
841
2291
  buildSimplifiedFormatRequest(request, userDid, expiresInDays, fieldName) {
842
2292
  // Build request with ONLY fields that are in AgentShield's schema
2293
+ // Defensive check: ensure scopes is always defined (should be guaranteed by validation)
2294
+ const scopes = request.scopes ?? [];
843
2295
  const simplifiedRequest = {
844
2296
  agent_did: request.agent_did,
845
- scopes: request.scopes,
2297
+ scopes,
846
2298
  expires_in_days: expiresInDays,
847
2299
  };
848
2300
  // Include user_identifier if we have userDid (matches AgentShield schema)
2301
+ // CRITICAL: user_identifier is optional - delegation works without it
849
2302
  if (userDid) {
850
2303
  simplifiedRequest.user_identifier = userDid;
2304
+ console.log("[ConsentService] Including user_identifier in delegation request:", {
2305
+ userDid: userDid.substring(0, 20) + "...",
2306
+ });
2307
+ }
2308
+ else {
2309
+ console.log("[ConsentService] No user_identifier (no OAuth or ephemeral DID) - delegation will proceed without it");
851
2310
  }
852
2311
  // AgentShield API only accepts "custom_fields", not "metadata"
853
- // Always use "custom_fields" regardless of Day0 config
2312
+ // Always use "custom_fields" regardless of Day0 config
854
2313
  if (userDid) {
2314
+ // Include issuer_did and subject_did in custom_fields when we have userDid
855
2315
  simplifiedRequest.custom_fields = {
856
2316
  issuer_did: userDid,
857
2317
  subject_did: request.agent_did,
@@ -865,6 +2325,7 @@ export class ConsentService {
865
2325
  // Include custom_fields from request even if no userDid
866
2326
  simplifiedRequest.custom_fields = request.customFields;
867
2327
  }
2328
+ // If no userDid and no customFields, custom_fields is omitted (valid)
868
2329
  // EXPLICIT SAFEGUARD: Remove session_id and project_id if they somehow got added
869
2330
  // This should never happen, but provides defense-in-depth
870
2331
  delete simplifiedRequest.session_id;
@@ -907,11 +2368,11 @@ export class ConsentService {
907
2368
  // FINAL SAFEGUARD: Remove session_id and project_id if they somehow got added
908
2369
  // This provides defense-in-depth protection
909
2370
  const sanitizedBody = { ...requestBody };
910
- if ('session_id' in sanitizedBody) {
2371
+ if ("session_id" in sanitizedBody) {
911
2372
  console.warn("[ConsentService] ⚠️ session_id detected in request body - removing (not in schema)");
912
2373
  delete sanitizedBody.session_id;
913
2374
  }
914
- if ('project_id' in sanitizedBody) {
2375
+ if ("project_id" in sanitizedBody) {
915
2376
  console.warn("[ConsentService] ⚠️ project_id detected in request body - removing (not in schema)");
916
2377
  delete sanitizedBody.project_id;
917
2378
  }
@@ -934,13 +2395,15 @@ export class ConsentService {
934
2395
  }
935
2396
  // Check for validation error specifically
936
2397
  if (response.status === 400) {
937
- const errorMessage = responseData
938
- ?.error?.message ||
939
- responseData?.message ||
940
- "Validation failed";
941
- if (errorMessage.includes("format") ||
2398
+ const errorData = responseData;
2399
+ const errorMessage = errorData.error?.message || errorData.message || "Validation failed";
2400
+ const errorCode = errorData.error_code || errorData.error?.code;
2401
+ // Check if error_code is explicitly set to "validation_error" OR error message suggests validation error
2402
+ if (errorCode === "validation_error" ||
2403
+ errorMessage.includes("format") ||
942
2404
  errorMessage.includes("schema") ||
943
- errorMessage.includes("invalid")) {
2405
+ errorMessage.includes("invalid") ||
2406
+ errorMessage.includes("Validation")) {
944
2407
  return {
945
2408
  success: false,
946
2409
  error: errorMessage,