@reproapp/node-sdk 0.0.45 → 0.0.47

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.
@@ -140,6 +140,9 @@ const selectSendBatch = (
140
140
  if (first.priority === 'normal') {
141
141
  return { batch: first, queueItems: 1 };
142
142
  }
143
+ if (first.attempts > 0) {
144
+ return { batch: first, queueItems: 1 };
145
+ }
143
146
 
144
147
  const entries = first.entries.slice();
145
148
  let bytes = serializedBytes(entries);
@@ -376,6 +379,16 @@ const drainIngestQueue = async (): Promise<void> => {
376
379
  backoffMs = 0;
377
380
  continue;
378
381
  }
382
+ if (
383
+ batch.priority === 'high' &&
384
+ selected.queueItems === 1 &&
385
+ ingestQueue.length > 1
386
+ ) {
387
+ const [failedItem] = ingestQueue.splice(0, 1);
388
+ if (failedItem) {
389
+ ingestQueue.push(failedItem);
390
+ }
391
+ }
379
392
 
380
393
  backoffMs = Math.min(
381
394
  DEFAULT_QUEUE_BACKOFF_MAX_MS,
package/src/privacy.ts CHANGED
@@ -79,6 +79,25 @@ type NormalizedStatement = {
79
79
  descendantRawTextMode?: RuntimePrivacyRawTextMode;
80
80
  };
81
81
 
82
+ type FunctionStatementTrieNode = {
83
+ children: Map<string, FunctionStatementTrieNode>;
84
+ terminal: NormalizedStatement[];
85
+ subtree: NormalizedStatement[];
86
+ };
87
+
88
+ type RuntimePrivacySurfaceIndex = {
89
+ contextualStatements: NormalizedStatement[];
90
+ fieldStatementsBySuffix: Map<string, NormalizedStatement[]>;
91
+ fieldWildcardTargetStatements: NormalizedStatement[];
92
+ };
93
+
94
+ type RuntimePrivacyStatementIndex = {
95
+ bySurface: Map<RuntimePrivacySurface, RuntimePrivacySurfaceIndex>;
96
+ functionStatements: FunctionStatementTrieNode;
97
+ candidateCache: Map<string, NormalizedStatement[]>;
98
+ hasNumericTargetSegments: boolean;
99
+ };
100
+
82
101
  type TransformFallback = {
83
102
  action: RuntimePrivacyAction;
84
103
  rawTextMode?: RuntimePrivacyRawTextMode;
@@ -120,13 +139,17 @@ export type NormalizedRuntimePrivacyPolicy = {
120
139
  strength?: 'weak' | 'medium' | 'strict';
121
140
  statements: NormalizedStatement[];
122
141
  rawTextHints: NormalizedRawTextHint[];
142
+ fingerprint: string;
123
143
  };
124
144
 
125
145
  const EMPTY_RUNTIME_PRIVACY_POLICY: NormalizedRuntimePrivacyPolicy = {
126
146
  statements: [],
127
147
  rawTextHints: [],
148
+ fingerprint: 'empty',
128
149
  };
129
150
 
151
+ const runtimePrivacyStatementIndexes = new WeakMap<object, RuntimePrivacyStatementIndex>();
152
+ const RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES = 2048;
130
153
  const RUNTIME_PRIVACY_PROVENANCE_MAX_ENTRIES = 1000;
131
154
  const RUNTIME_PRIVACY_PROVENANCE_MAX_SERIALIZED_LENGTH = 200_000;
132
155
  let objectRuntimePrivacyProvenance = new WeakMap<object, RuntimePrivacyProvenance>();
@@ -303,16 +326,16 @@ export function normalizeRuntimePrivacyPolicy(raw: unknown): NormalizedRuntimePr
303
326
  const policy = raw as Record<string, any>;
304
327
  const flattened = flattenStatements(policy);
305
328
  const rawTextHints = normalizeRawTextHints(policy.rawTextHints);
306
- const statements: NormalizedStatement[] = flattened
329
+ const statements = deduplicateNormalizedStatements(flattened
307
330
  .map((entry, index) => normalizeStatement(entry, index))
308
331
  .filter((entry): entry is NormalizedStatement => entry !== null)
309
- .sort((left, right) => left.priority - right.priority);
332
+ .sort((left, right) => left.priority - right.priority));
310
333
 
311
334
  if (!statements.length) {
312
335
  return null;
313
336
  }
314
337
 
315
- return {
338
+ const normalizedPolicy: NormalizedRuntimePrivacyPolicy = {
316
339
  environment: typeof policy.environment === 'string' ? policy.environment : undefined,
317
340
  strength:
318
341
  policy.strength === 'weak' || policy.strength === 'medium' || policy.strength === 'strict'
@@ -320,7 +343,15 @@ export function normalizeRuntimePrivacyPolicy(raw: unknown): NormalizedRuntimePr
320
343
  : undefined,
321
344
  statements,
322
345
  rawTextHints,
346
+ fingerprint: runtimePrivacyPolicyFingerprint(
347
+ typeof policy.environment === 'string' ? policy.environment : undefined,
348
+ typeof policy.strength === 'string' ? policy.strength : undefined,
349
+ statements,
350
+ rawTextHints,
351
+ ),
323
352
  };
353
+ runtimePrivacyStatementIndexes.set(normalizedPolicy, buildRuntimePrivacyStatementIndex(statements));
354
+ return normalizedPolicy;
324
355
  }
325
356
 
326
357
  export function applyRuntimePrivacyPolicy(
@@ -425,6 +456,365 @@ function normalizeStatement(input: any, index: number): NormalizedStatement | nu
425
456
  };
426
457
  }
427
458
 
459
+ function deduplicateNormalizedStatements(statements: NormalizedStatement[]): NormalizedStatement[] {
460
+ const seen = new Set<string>();
461
+ const deduplicated: NormalizedStatement[] = [];
462
+ for (const statement of statements) {
463
+ const key = normalizedStatementSemanticKey(statement);
464
+ if (seen.has(key)) {
465
+ continue;
466
+ }
467
+ seen.add(key);
468
+ deduplicated.push(statement);
469
+ }
470
+ return deduplicated;
471
+ }
472
+
473
+ function normalizedStatementSemanticKey(statement: NormalizedStatement): string {
474
+ return JSON.stringify({
475
+ scope: statement.scope,
476
+ selectorTokens: statement.selectorTokens,
477
+ selectorLeaves: statement.selectorLeaves,
478
+ action: statement.action,
479
+ pattern: statement.pattern
480
+ ? {
481
+ source: statement.pattern.source,
482
+ flags: statement.pattern.flags,
483
+ }
484
+ : null,
485
+ targetMatchers: statement.targetMatchers.map((matcher) => ({
486
+ surfaces: matcher.surfaces,
487
+ path: matcher.path,
488
+ })),
489
+ textPolicy: statement.textPolicy ?? null,
490
+ rawTextMode: statement.rawTextMode ?? null,
491
+ rawTextHintNames: statement.rawTextHintNames ?? null,
492
+ descendantRawTextMode: statement.descendantRawTextMode ?? null,
493
+ });
494
+ }
495
+
496
+ function runtimePrivacyPolicyFingerprint(
497
+ environment: string | undefined,
498
+ strength: string | undefined,
499
+ statements: NormalizedStatement[],
500
+ rawTextHints: NormalizedRawTextHint[],
501
+ ): string {
502
+ const serialized = JSON.stringify({
503
+ environment: environment ?? null,
504
+ strength: strength ?? null,
505
+ statements: statements.map(normalizedStatementSemanticKey),
506
+ rawTextHints: rawTextHints.map((hint) => ({
507
+ name: hint.name,
508
+ action: hint.action,
509
+ pattern: hint.pattern.source,
510
+ flags: hint.pattern.flags,
511
+ tokenLabel: hint.tokenLabel,
512
+ })),
513
+ });
514
+ return createHash('sha256').update(serialized).digest('hex');
515
+ }
516
+
517
+ function createFunctionStatementTrieNode(): FunctionStatementTrieNode {
518
+ return {
519
+ children: new Map<string, FunctionStatementTrieNode>(),
520
+ terminal: [],
521
+ subtree: [],
522
+ };
523
+ }
524
+
525
+ function appendUniqueStatement(target: NormalizedStatement[], statement: NormalizedStatement): void {
526
+ if (!target.includes(statement)) {
527
+ target.push(statement);
528
+ }
529
+ }
530
+
531
+ function functionSelectorSegments(value: string): string[] {
532
+ return normalizeFunctionSelector(value)
533
+ .split('.')
534
+ .map((segment) => segment.trim())
535
+ .filter(Boolean)
536
+ .reverse();
537
+ }
538
+
539
+ function addFunctionStatementToTrie(
540
+ root: FunctionStatementTrieNode,
541
+ selector: string,
542
+ statement: NormalizedStatement,
543
+ ): void {
544
+ const segments = functionSelectorSegments(selector);
545
+ if (!segments.length) {
546
+ return;
547
+ }
548
+
549
+ let node = root;
550
+ for (const segment of segments) {
551
+ let child = node.children.get(segment);
552
+ if (!child) {
553
+ child = createFunctionStatementTrieNode();
554
+ node.children.set(segment, child);
555
+ }
556
+ node = child;
557
+ appendUniqueStatement(node.subtree, statement);
558
+ }
559
+ appendUniqueStatement(node.terminal, statement);
560
+ }
561
+
562
+ function createRuntimePrivacySurfaceIndex(): RuntimePrivacySurfaceIndex {
563
+ return {
564
+ contextualStatements: [],
565
+ fieldStatementsBySuffix: new Map<string, NormalizedStatement[]>(),
566
+ fieldWildcardTargetStatements: [],
567
+ };
568
+ }
569
+
570
+ function appendStatementToMap(
571
+ target: Map<string, NormalizedStatement[]>,
572
+ key: string,
573
+ statement: NormalizedStatement,
574
+ ): void {
575
+ if (!key) {
576
+ return;
577
+ }
578
+ const current = target.get(key);
579
+ if (current) {
580
+ appendUniqueStatement(current, statement);
581
+ return;
582
+ }
583
+ target.set(key, [statement]);
584
+ }
585
+
586
+ function explicitTargetSurfaces(statement: NormalizedStatement): Set<RuntimePrivacySurface> | null {
587
+ if (!statement.targetMatchers.length) {
588
+ return null;
589
+ }
590
+ return new Set(statement.targetMatchers.flatMap((matcher) => matcher.surfaces));
591
+ }
592
+
593
+ function scopeCanMatchSurface(
594
+ statement: NormalizedStatement,
595
+ surface: RuntimePrivacySurface,
596
+ ): boolean {
597
+ if (statement.scope === 'FUNCTION') {
598
+ return surface.startsWith('trace.');
599
+ }
600
+ if (statement.scope === 'ROUTE') {
601
+ return surface.startsWith('request.') || surface === 'response.body';
602
+ }
603
+ if (statement.scope === 'SCHEMA' || statement.scope === 'QUERY') {
604
+ return surface.startsWith('db.');
605
+ }
606
+ return true;
607
+ }
608
+
609
+ function addFieldStatementToSurfaceIndex(
610
+ index: RuntimePrivacySurfaceIndex,
611
+ statement: NormalizedStatement,
612
+ ): void {
613
+ for (const selector of statement.selectorLeaves) {
614
+ appendStatementToMap(
615
+ index.fieldStatementsBySuffix,
616
+ normalizeComparableToken(selector),
617
+ statement,
618
+ );
619
+ }
620
+ for (const matcher of statement.targetMatchers) {
621
+ const terminal = matcher.path?.[matcher.path.length - 1];
622
+ if (!terminal) {
623
+ continue;
624
+ }
625
+ if (terminal === '*') {
626
+ appendUniqueStatement(index.fieldWildcardTargetStatements, statement);
627
+ continue;
628
+ }
629
+ appendStatementToMap(
630
+ index.fieldStatementsBySuffix,
631
+ normalizeComparableToken(terminal),
632
+ statement,
633
+ );
634
+ }
635
+ }
636
+
637
+ function buildRuntimePrivacyStatementIndex(
638
+ statements: NormalizedStatement[],
639
+ ): RuntimePrivacyStatementIndex {
640
+ const bySurface = new Map<RuntimePrivacySurface, RuntimePrivacySurfaceIndex>(
641
+ ALL_SURFACES.map((surface) => [surface, createRuntimePrivacySurfaceIndex()]),
642
+ );
643
+ const functionStatements = createFunctionStatementTrieNode();
644
+ let hasNumericTargetSegments = false;
645
+
646
+ for (const statement of statements) {
647
+ for (const matcher of statement.targetMatchers) {
648
+ if (matcher.path?.some((segment) => /^\d+$/.test(segment))) {
649
+ hasNumericTargetSegments = true;
650
+ }
651
+ }
652
+
653
+ if (statement.scope === 'FUNCTION') {
654
+ for (const selector of statement.selectorTokens) {
655
+ addFunctionStatementToTrie(functionStatements, selector, statement);
656
+ }
657
+ continue;
658
+ }
659
+
660
+ const targetSurfaces = explicitTargetSurfaces(statement);
661
+ for (const surface of ALL_SURFACES) {
662
+ if (!scopeCanMatchSurface(statement, surface)) {
663
+ continue;
664
+ }
665
+ // FIELD protections deliberately have cross-surface fallback semantics.
666
+ if (
667
+ statement.scope !== 'FIELD' &&
668
+ targetSurfaces &&
669
+ !targetSurfaces.has(surface)
670
+ ) {
671
+ continue;
672
+ }
673
+ const surfaceIndex = bySurface.get(surface)!;
674
+ if (statement.scope === 'FIELD') {
675
+ addFieldStatementToSurfaceIndex(surfaceIndex, statement);
676
+ } else {
677
+ appendUniqueStatement(surfaceIndex.contextualStatements, statement);
678
+ }
679
+ }
680
+ }
681
+
682
+ return {
683
+ bySurface,
684
+ functionStatements,
685
+ candidateCache: new Map<string, NormalizedStatement[]>(),
686
+ hasNumericTargetSegments,
687
+ };
688
+ }
689
+
690
+ function getRuntimePrivacyStatementIndex(
691
+ policy: NormalizedRuntimePrivacyPolicy,
692
+ ): RuntimePrivacyStatementIndex {
693
+ const existing = runtimePrivacyStatementIndexes.get(policy);
694
+ if (existing) {
695
+ return existing;
696
+ }
697
+ const created = buildRuntimePrivacyStatementIndex(policy.statements);
698
+ runtimePrivacyStatementIndexes.set(policy, created);
699
+ return created;
700
+ }
701
+
702
+ function matchingFunctionStatements(
703
+ root: FunctionStatementTrieNode,
704
+ functionName: string,
705
+ ): NormalizedStatement[] {
706
+ const segments = functionSelectorSegments(functionName);
707
+ if (!segments.length) {
708
+ return [];
709
+ }
710
+
711
+ const matches = new Set<NormalizedStatement>();
712
+ let node: FunctionStatementTrieNode | undefined = root;
713
+ for (let index = 0; index < segments.length; index += 1) {
714
+ node = node.children.get(segments[index]);
715
+ if (!node) {
716
+ break;
717
+ }
718
+ for (const statement of node.terminal) {
719
+ matches.add(statement);
720
+ }
721
+ if (index === segments.length - 1) {
722
+ for (const statement of node.subtree) {
723
+ matches.add(statement);
724
+ }
725
+ }
726
+ }
727
+ return Array.from(matches);
728
+ }
729
+
730
+ function normalizedPathSuffixes(path: string[], preserveNumericSegments: boolean): string[] {
731
+ const joined = path
732
+ .filter((segment) =>
733
+ segment !== '*' &&
734
+ (preserveNumericSegments || !/^\d+$/.test(segment)),
735
+ )
736
+ .map(normalizeComparableToken)
737
+ .filter(Boolean)
738
+ .join('.');
739
+ if (!joined) {
740
+ return [];
741
+ }
742
+ const segments = joined.split('.').filter(Boolean);
743
+ const suffixes: string[] = [];
744
+ for (let index = 0; index < segments.length; index += 1) {
745
+ suffixes.push(segments.slice(index).join('.'));
746
+ }
747
+ return suffixes;
748
+ }
749
+
750
+ function runtimePrivacyCandidateCacheKey(
751
+ index: RuntimePrivacyStatementIndex,
752
+ surface: RuntimePrivacySurface,
753
+ context: RuntimePrivacyApplyContext,
754
+ path: string[],
755
+ ): string {
756
+ const pathKey = path
757
+ .map((segment) =>
758
+ !index.hasNumericTargetSegments && /^\d+$/.test(segment)
759
+ ? '*'
760
+ : segment,
761
+ )
762
+ .join('.');
763
+ return [
764
+ surface,
765
+ normalizeFunctionSelector(context.trace?.fn?.trim() ?? ''),
766
+ context.routeKey?.trim() ?? '',
767
+ String(context.db?.type ?? ''),
768
+ String(context.db?.collection ?? ''),
769
+ String(context.db?.op ?? ''),
770
+ pathKey,
771
+ ].join('\u0000');
772
+ }
773
+
774
+ function candidateStatementsForMatch(
775
+ policy: NormalizedRuntimePrivacyPolicy,
776
+ surface: RuntimePrivacySurface,
777
+ context: RuntimePrivacyApplyContext,
778
+ path: string[],
779
+ ): NormalizedStatement[] {
780
+ const index = getRuntimePrivacyStatementIndex(policy);
781
+ const cacheKey = runtimePrivacyCandidateCacheKey(index, surface, context, path);
782
+ const cached = index.candidateCache.get(cacheKey);
783
+ if (cached) {
784
+ return cached;
785
+ }
786
+
787
+ const surfaceIndex = index.bySurface.get(surface);
788
+ const candidateSet = new Set<NormalizedStatement>(
789
+ surfaceIndex?.contextualStatements ?? [],
790
+ );
791
+ if (surfaceIndex && path.length) {
792
+ for (const suffix of normalizedPathSuffixes(path, index.hasNumericTargetSegments)) {
793
+ for (const statement of surfaceIndex.fieldStatementsBySuffix.get(suffix) ?? []) {
794
+ candidateSet.add(statement);
795
+ }
796
+ }
797
+ for (const statement of surfaceIndex.fieldWildcardTargetStatements) {
798
+ candidateSet.add(statement);
799
+ }
800
+ }
801
+ if (surface.startsWith('trace.')) {
802
+ const functionName = context.trace?.fn?.trim() ?? '';
803
+ if (functionName) {
804
+ for (const statement of matchingFunctionStatements(index.functionStatements, functionName)) {
805
+ candidateSet.add(statement);
806
+ }
807
+ }
808
+ }
809
+ const candidates = Array.from(candidateSet)
810
+ .sort((left, right) => left.priority - right.priority);
811
+ if (index.candidateCache.size >= RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES) {
812
+ index.candidateCache.clear();
813
+ }
814
+ index.candidateCache.set(cacheKey, candidates);
815
+ return candidates;
816
+ }
817
+
428
818
  function resolveEffectiveAction(
429
819
  baseAction: RuntimePrivacyAction | null | undefined,
430
820
  value: any,
@@ -1299,7 +1689,7 @@ function matchStatement(
1299
1689
  path: string[],
1300
1690
  ): NormalizedStatement | null {
1301
1691
  let candidates: Array<{ statement: NormalizedStatement; targetScore: number; score: number }> = [];
1302
- for (const statement of policy.statements) {
1692
+ for (const statement of candidateStatementsForMatch(policy, surface, context, path)) {
1303
1693
  if (shouldIgnoreBroadContainerRuleOnDescendant(statement, surface, path)) {
1304
1694
  continue;
1305
1695
  }
@@ -1705,12 +2095,24 @@ function querySelectorMatches(
1705
2095
 
1706
2096
  function patternMatches(pattern: RegExp | undefined, leaf: string, joinedPath: string, value: any): boolean {
1707
2097
  if (!pattern) return false;
1708
- if (leaf && pattern.test(leaf)) return true;
1709
- if (joinedPath && pattern.test(joinedPath)) return true;
1710
- if (typeof value === 'string' && pattern.test(value)) return true;
2098
+ if (leaf && testRuntimePrivacyPattern(pattern, leaf)) return true;
2099
+ if (joinedPath && testRuntimePrivacyPattern(pattern, joinedPath)) return true;
2100
+ if (typeof value === 'string' && testRuntimePrivacyPattern(pattern, value)) return true;
1711
2101
  return false;
1712
2102
  }
1713
2103
 
2104
+ function testRuntimePrivacyPattern(pattern: RegExp, value: string): boolean {
2105
+ // Generated policies may contain global/sticky expressions. RegExp.test mutates
2106
+ // lastIndex for those flags, which otherwise makes identical captures alternate
2107
+ // between matched and unmatched.
2108
+ pattern.lastIndex = 0;
2109
+ try {
2110
+ return pattern.test(value);
2111
+ } finally {
2112
+ pattern.lastIndex = 0;
2113
+ }
2114
+ }
2115
+
1714
2116
  function functionSelectorMatches(selector: string, fn: string): boolean {
1715
2117
  const normalizedSelector = normalizeFunctionSelector(selector);
1716
2118
  const normalizedFn = normalizeFunctionSelector(fn);
@@ -0,0 +1,76 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const originalFetch = global.fetch;
4
+ const attempts = [];
5
+
6
+ global.fetch = async (_url, init = {}) => {
7
+ const body = JSON.parse(String(init.body || '{}'));
8
+ const requestIds = (body.events || []).map((event) => event?.payload?.requestRid);
9
+ attempts.push(requestIds);
10
+ const hasPoisonedItem = requestIds.includes('R_poisoned');
11
+ return {
12
+ ok: !hasPoisonedItem,
13
+ status: hasPoisonedItem ? 503 : 202,
14
+ json: async () => ({ ok: !hasPoisonedItem }),
15
+ text: async () => JSON.stringify({ ok: !hasPoisonedItem }),
16
+ };
17
+ };
18
+
19
+ const { enqueueIngestEntries, flushIngestQueue } = require('../dist/ingest/client');
20
+
21
+ function manifest(requestRid) {
22
+ return {
23
+ actionId: 'A_fairness',
24
+ requestRid,
25
+ requestCaptureManifest: {
26
+ scopeId: `req:${requestRid}`,
27
+ requestRid,
28
+ transport: 'http',
29
+ expected: {
30
+ backendRequest: 1,
31
+ traceBatch: 0,
32
+ dbChange: 0,
33
+ emailEvent: 0,
34
+ },
35
+ closedAt: Date.now(),
36
+ },
37
+ t: Date.now(),
38
+ };
39
+ }
40
+
41
+ async function main() {
42
+ const cfg = {
43
+ tenantId: 'TENANT_test',
44
+ appId: 'APP_test',
45
+ appSecret: 'secret',
46
+ serviceName: 'ingest-fairness-test',
47
+ ingestBase: 'http://127.0.0.1:65535',
48
+ };
49
+
50
+ enqueueIngestEntries(cfg, 'S_fairness', [manifest('R_poisoned')], { priority: 'high' });
51
+ enqueueIngestEntries(cfg, 'S_fairness', [manifest('R_valid')], { priority: 'high' });
52
+
53
+ await flushIngestQueue();
54
+ await flushIngestQueue();
55
+ await flushIngestQueue();
56
+
57
+ assert(
58
+ attempts.some((requestIds) => requestIds.length === 1 && requestIds[0] === 'R_valid'),
59
+ `valid completion evidence remained blocked: ${JSON.stringify(attempts)}`,
60
+ );
61
+ assert(
62
+ attempts.filter((requestIds) => requestIds.includes('R_poisoned')).length >= 2,
63
+ `failed high-priority evidence was dropped instead of retained: ${JSON.stringify(attempts)}`,
64
+ );
65
+
66
+ console.log('ingest high priority fairness OK');
67
+ }
68
+
69
+ main()
70
+ .catch((error) => {
71
+ console.error(error);
72
+ process.exitCode = 1;
73
+ })
74
+ .finally(() => {
75
+ global.fetch = originalFetch;
76
+ });