@usageflow/core 0.4.1 → 0.4.3

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/src/base.ts CHANGED
@@ -318,7 +318,7 @@ export abstract class UsageFlowAPI {
318
318
  public guessLedgerId(
319
319
  request: UsageFlowRequest,
320
320
  overrideUrl?: string,
321
- ): { ledgerId: string, hasLimit: boolean } {
321
+ ): { ledgerId: string, hasLimit: boolean, responseTrackingField?: string } {
322
322
  const method = request.method;
323
323
  const url = overrideUrl || this.getRoutePattern(request);
324
324
  const configs = this.apiConfigs;
@@ -331,23 +331,25 @@ export abstract class UsageFlowAPI {
331
331
  const fieldName = config.identityFieldName!;
332
332
  const location = config.identityFieldLocation;
333
333
  const hasLimit = config.hasRateLimit || false;
334
+ const isResponseTrackingEnabled = config.isResponseTrackingEnabled;
335
+ const responseTrackingField = isResponseTrackingEnabled ? config.responseTrackingField : undefined;
334
336
 
335
337
  if (method === config.method && url === config.url) {
336
338
 
337
339
  switch (location) {
338
340
  case "path_params":
339
341
  if (request.params?.[fieldName]) {
340
- return { ledgerId: `${method} ${url} ${request.params[fieldName]}`, hasLimit };
342
+ return { ledgerId: `${method} ${url} ${request.params[fieldName]}`, hasLimit, responseTrackingField };
341
343
  }
342
344
  break;
343
345
  case "query_params":
344
346
  if (request.query?.[fieldName]) {
345
- return { ledgerId: `${method} ${url} ${request.query[fieldName]}`, hasLimit };
347
+ return { ledgerId: `${method} ${url} ${request.query[fieldName]}`, hasLimit, responseTrackingField };
346
348
  }
347
349
  break;
348
350
  case "body":
349
351
  if (request.body?.[fieldName]) {
350
- return { ledgerId: `${method} ${url} ${request.body[fieldName]}`, hasLimit };
352
+ return { ledgerId: `${method} ${url} ${request.body[fieldName]}`, hasLimit, responseTrackingField };
351
353
  }
352
354
  break;
353
355
  case "bearer_token":
@@ -359,7 +361,7 @@ export abstract class UsageFlowAPI {
359
361
  if (token) {
360
362
  const claims = this.decodeJwtUnverified(token);
361
363
  if (claims?.[fieldName]) {
362
- return { ledgerId: `${method} ${url} ${claims[fieldName]}`, hasLimit };
364
+ return { ledgerId: `${method} ${url} ${claims[fieldName]}`, hasLimit, responseTrackingField };
363
365
  }
364
366
  }
365
367
  break;
@@ -367,7 +369,38 @@ export abstract class UsageFlowAPI {
367
369
  case "headers": {
368
370
  const headerValue = this.getHeaderValue(request.headers, fieldName);
369
371
  if (headerValue) {
370
- return { ledgerId: `${method} ${url} ${headerValue}`, hasLimit };
372
+ return { ledgerId: `${method} ${url} ${headerValue}`, hasLimit, responseTrackingField };
373
+ }
374
+ break;
375
+ }
376
+
377
+ case "cookie": {
378
+ // Handle JWT cookie format: '[technique=jwt]cookieName[pick=claim]'
379
+ const jwtCookieMatch = this.parseJwtCookieField(fieldName);
380
+ if (jwtCookieMatch) {
381
+ const { cookieName, claim } = jwtCookieMatch;
382
+ const cookieValue = this.getCookieValue(request.headers, cookieName);
383
+ if (cookieValue) {
384
+ const claims = this.decodeJwtUnverified(cookieValue);
385
+ if (claims?.[claim]) {
386
+ return { ledgerId: `${method} ${url} ${claims[claim]}`, hasLimit, responseTrackingField };
387
+ }
388
+ }
389
+ break;
390
+ }
391
+
392
+ // Handle standard cookie access (e.g., "cookie.session" or "session")
393
+ let cookieValue: string | null = null;
394
+ if (fieldName.toLowerCase().startsWith('cookie.')) {
395
+ const cookieName = fieldName.substring(7); // Remove "cookie." prefix
396
+ cookieValue = this.getCookieValue(request.headers, cookieName);
397
+ } else {
398
+ // Use dot notation for regular headers
399
+ cookieValue = this.getByDotNotation(request.headers, fieldName);
400
+ }
401
+
402
+ if (cookieValue) {
403
+ return { ledgerId: `${method} ${url} ${cookieValue}`, hasLimit, responseTrackingField };
371
404
  }
372
405
  break;
373
406
  }
@@ -375,7 +408,7 @@ export abstract class UsageFlowAPI {
375
408
  }
376
409
  }
377
410
 
378
- return { ledgerId: `${method} ${url}`, hasLimit: false };
411
+ return { ledgerId: `${method} ${url}`, hasLimit: false, responseTrackingField: undefined };
379
412
  }
380
413
 
381
414
  private async fetchApiPolicies(): Promise<void> {
@@ -445,6 +478,7 @@ export abstract class UsageFlowAPI {
445
478
  payload: RequestForAllocation,
446
479
  metadata: RequestMetadata,
447
480
  hasLimit: boolean,
481
+ responseTrackingField?: string,
448
482
  ): Promise<void> {
449
483
  if (this.socketManager && this.socketManager.isConnected()) {
450
484
  try {
@@ -477,6 +511,7 @@ export abstract class UsageFlowAPI {
477
511
  });
478
512
  request.usageflow!.eventId = payload.allocationId;
479
513
  request.usageflow!.metadata = metadata;
514
+ request.usageflow!.responseTrackingField = responseTrackingField;
480
515
  }
481
516
  } catch (error: any) {
482
517
  console.error(
@@ -648,4 +683,287 @@ export abstract class UsageFlowAPI {
648
683
  public getBlockedEndpoints(): Map<string, boolean> {
649
684
  return this.blockedEndpoints;
650
685
  }
686
+
687
+ private getByDotNotation(object: any, path: string): any {
688
+ return path.split('.').reduce((acc, part) => acc?.[part], object);
689
+ }
690
+
691
+ /**
692
+ * Extract a value from an object using a dot-notation path
693
+ * Supports nested paths like "data.amount" or "result.items[0].count"
694
+ * Supports wildcard array notation like "items[*].id" to iterate over arrays
695
+ * @param obj - The object to extract the value from
696
+ * @param path - The dot-notation path to the value (e.g., "data.amount" or "items[*].id")
697
+ * @returns The value at the path, or undefined if not found. For wildcard arrays, returns an array of values.
698
+ */
699
+ public getValueByPath(obj: any, path: string): any {
700
+ if (!obj || !path) {
701
+ return undefined;
702
+ }
703
+
704
+ // Parse path into segments, preserving [*] as a special marker
705
+ const segments: Array<string | '*'> = [];
706
+ let currentSegment = '';
707
+ let i = 0;
708
+
709
+ while (i < path.length) {
710
+ if (path[i] === '[' && i + 1 < path.length && path[i + 1] === '*') {
711
+ // Found [*]
712
+ if (currentSegment) {
713
+ segments.push(currentSegment);
714
+ currentSegment = '';
715
+ }
716
+ segments.push('*');
717
+ i += 3; // Skip '[', '*', ']'
718
+ } else if (path[i] === '[') {
719
+ // Found indexed array access like [0]
720
+ if (currentSegment) {
721
+ segments.push(currentSegment);
722
+ currentSegment = '';
723
+ }
724
+ // Extract the index
725
+ let index = '';
726
+ i++; // Skip '['
727
+ while (i < path.length && path[i] !== ']') {
728
+ index += path[i];
729
+ i++;
730
+ }
731
+ if (index) {
732
+ segments.push(index);
733
+ }
734
+ i++; // Skip ']'
735
+ } else if (path[i] === '.') {
736
+ if (currentSegment) {
737
+ segments.push(currentSegment);
738
+ currentSegment = '';
739
+ }
740
+ i++;
741
+ } else {
742
+ currentSegment += path[i];
743
+ i++;
744
+ }
745
+ }
746
+
747
+ if (currentSegment) {
748
+ segments.push(currentSegment);
749
+ }
750
+
751
+ // Recursive helper function to process segments
752
+ const processSegments = (currentObj: any, segs: Array<string | '*'>, segIndex: number): any => {
753
+ if (currentObj === null || currentObj === undefined) {
754
+ return undefined;
755
+ }
756
+
757
+ // If we've processed all segments, return the current value
758
+ if (segIndex >= segs.length) {
759
+ return currentObj;
760
+ }
761
+
762
+ const segment = segs[segIndex];
763
+
764
+ // Handle wildcard array iteration
765
+ if (segment === '*') {
766
+ if (!Array.isArray(currentObj)) {
767
+ return undefined;
768
+ }
769
+
770
+ // If this is the last segment, return the first element of the array
771
+ if (segIndex === segs.length - 1) {
772
+ return currentObj.length > 0 ? currentObj[0] : undefined;
773
+ }
774
+
775
+ // Otherwise, iterate over the array and return the first valid result
776
+ for (const item of currentObj) {
777
+ const result = processSegments(item, segs, segIndex + 1);
778
+ if (result !== undefined) {
779
+ return result;
780
+ }
781
+ }
782
+
783
+ return undefined;
784
+ }
785
+
786
+ // Handle regular property access
787
+ const nextObj = currentObj[segment];
788
+ return processSegments(nextObj, segs, segIndex + 1);
789
+ };
790
+
791
+ return processSegments(obj, segments, 0);
792
+ }
793
+
794
+ /**
795
+ * Parse and extract a specific cookie value from the Cookie header
796
+ * @param headers The request headers object
797
+ * @param cookieName The name of the cookie to extract (e.g., "session")
798
+ * @returns The cookie value or null if not found
799
+ */
800
+ private getCookieValue(
801
+ headers: Record<string, string | string[] | undefined> | Headers,
802
+ cookieName: string,
803
+ ): string | null {
804
+ let cookieHeader = this.getHeaderValue(headers, 'cookie');
805
+ if (!cookieHeader) {
806
+ cookieHeader = this.getHeaderValue(headers, 'Cookie');
807
+ }
808
+
809
+ if (!cookieHeader) {
810
+ return null;
811
+ }
812
+
813
+ // Parse cookies from the Cookie header string
814
+ // Format: "name1=value1; name2=value2; name3=value3"
815
+ const cookies = cookieHeader.split(';').map(cookie => {
816
+ const [name, ...valueParts] = cookie.trim().split('=');
817
+ return {
818
+ name: name.trim(),
819
+ value: valueParts.join('=').trim(), // Handle values that might contain '='
820
+ };
821
+ });
822
+
823
+ // Find the cookie with the matching name (case-insensitive)
824
+ const cookie = cookies.find(c => c.name.toLowerCase() === cookieName.toLowerCase());
825
+ return cookie ? cookie.value : null;
826
+ }
827
+
828
+ /**
829
+ * Parse JWT cookie field format: '[technique=jwt]cookieName[pick=claim]'
830
+ * @param fieldName The field name to parse
831
+ * @returns Object with cookieName and claim, or null if not a JWT cookie format
832
+ */
833
+ private parseJwtCookieField(
834
+ fieldName: string,
835
+ ): { cookieName: string; claim: string } | null {
836
+ const techniqueMatch = fieldName.match(/^\[technique=([^\]]+)\]/);
837
+ if (!techniqueMatch || techniqueMatch[1] !== 'jwt') {
838
+ return null;
839
+ }
840
+
841
+ const pickMatch = fieldName.match(/\[pick=([^\]]+)\]/);
842
+ if (!pickMatch) {
843
+ return null;
844
+ }
845
+
846
+ // Extract cookie name: everything between [technique=jwt] and [pick=...]
847
+ const techniqueEnd = techniqueMatch[0].length;
848
+ const pickStart = fieldName.indexOf('[pick=');
849
+ if (pickStart === -1 || pickStart <= techniqueEnd) {
850
+ return null;
851
+ }
852
+
853
+ const cookieName = fieldName.substring(techniqueEnd, pickStart);
854
+ if (!cookieName) {
855
+ return null;
856
+ }
857
+
858
+ return {
859
+ cookieName,
860
+ claim: pickMatch[1],
861
+ };
862
+ }
863
+
864
+ public getResponseBody(response: Response): any {
865
+ if (response.body) {
866
+ return response.body;
867
+ }
868
+ return null;
869
+ }
870
+
871
+ /**
872
+ * Extract schema from a JavaScript object/array
873
+ * Recursively processes objects, arrays, and primitives to build a schema structure
874
+ * @param obj - The object to extract schema from
875
+ * @param path - The current path in the object hierarchy (for nested objects)
876
+ * @returns Schema object with type information and paths
877
+ */
878
+ public extractSchema(obj: any, path: string = ''): any {
879
+ // Handle null
880
+ if (obj === null || obj === undefined) {
881
+ return 'null';
882
+ }
883
+
884
+ // Handle arrays
885
+ if (Array.isArray(obj)) {
886
+ if (obj.length === 0) {
887
+ return 'array';
888
+ }
889
+ // Get schema of first element as representative
890
+ return {
891
+ type: 'array',
892
+ items: this.extractSchema(obj[0], path),
893
+ };
894
+ }
895
+
896
+ // Handle objects
897
+ if (typeof obj === 'object' && obj !== null) {
898
+ const schema: Record<string, any> = {};
899
+ for (const [key, value] of Object.entries(obj)) {
900
+ const fieldPath = path ? `${path}.${key}` : key;
901
+
902
+ if (value === null || value === undefined) {
903
+ schema[key] = {
904
+ type: 'null',
905
+ path: fieldPath,
906
+ };
907
+ } else if (typeof value === 'boolean') {
908
+ // Check bool before number (important for type detection)
909
+ schema[key] = {
910
+ type: 'boolean',
911
+ path: fieldPath,
912
+ };
913
+ } else if (typeof value === 'number') {
914
+ // Check if it's an integer or float
915
+ if (Number.isInteger(value)) {
916
+ schema[key] = {
917
+ type: 'integer',
918
+ path: fieldPath,
919
+ };
920
+ } else {
921
+ schema[key] = {
922
+ type: 'float',
923
+ path: fieldPath,
924
+ };
925
+ }
926
+ } else if (typeof value === 'string') {
927
+ schema[key] = {
928
+ type: 'string',
929
+ path: fieldPath,
930
+ };
931
+ } else if (Array.isArray(value)) {
932
+ schema[key] = {
933
+ type: 'array',
934
+ path: fieldPath,
935
+ items: value.length > 0 ? this.extractSchema(value[0], fieldPath) : 'unknown',
936
+ };
937
+ } else if (typeof value === 'object') {
938
+ schema[key] = {
939
+ type: 'object',
940
+ path: fieldPath,
941
+ properties: this.extractSchema(value, fieldPath),
942
+ };
943
+ } else {
944
+ schema[key] = {
945
+ type: typeof value,
946
+ path: fieldPath,
947
+ };
948
+ }
949
+ }
950
+
951
+ return schema;
952
+ }
953
+
954
+ // Handle primitive types
955
+ if (typeof obj === 'boolean') {
956
+ return 'boolean';
957
+ } else if (typeof obj === 'number') {
958
+ if (Number.isInteger(obj)) {
959
+ return 'integer';
960
+ } else {
961
+ return 'float';
962
+ }
963
+ } else if (typeof obj === 'string') {
964
+ return 'string';
965
+ } else {
966
+ return typeof obj;
967
+ }
968
+ }
651
969
  }
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@ declare global {
5
5
  startTime: number;
6
6
  eventId?: string;
7
7
  metadata?: RequestMetadata;
8
+ responseTrackingField?: string;
8
9
  };
9
10
  baseUrl: string;
10
11
  }
@@ -16,6 +17,8 @@ declare global {
16
17
  startTime: number;
17
18
  eventId?: string;
18
19
  metadata?: RequestMetadata;
20
+ responseTrackingField?: string;
21
+
19
22
  };
20
23
  }
21
24
  }
@@ -26,6 +29,7 @@ declare global {
26
29
  startTime: number;
27
30
  eventId?: string;
28
31
  metadata?: RequestMetadata;
32
+ responseTrackingField?: string;
29
33
  };
30
34
  }
31
35
  }
@@ -63,6 +67,9 @@ export interface UsageFlowConfig {
63
67
  identityFieldName?: string;
64
68
  identityFieldLocation?: string;
65
69
  hasRateLimit?: boolean;
70
+ responseTrackingField?: string;
71
+ isResponseTrackingEnabled?: boolean;
72
+
66
73
  }
67
74
 
68
75
  export interface BlockedEndpoint {
@@ -140,6 +147,7 @@ export interface UsageFlowRequest {
140
147
  eventId?: string;
141
148
  metadata?: RequestMetadata;
142
149
  startTime?: number;
150
+ responseTrackingField?: string;
143
151
  };
144
152
  routeOptions?: {
145
153
  url: string;