@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.60

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 (72) hide show
  1. package/AUTHORING.md +16 -0
  2. package/CHANGELOG.md +4 -0
  3. package/bin/apifuse-dev.ts +2 -1
  4. package/bin/apifuse-perf.ts +41 -4
  5. package/bin/apifuse-record.ts +3 -2
  6. package/dist/config/loader.js +13 -12
  7. package/dist/engine.js +3 -2
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lint.js +36 -15
  11. package/dist/provider.d.ts +1 -1
  12. package/dist/provider.js +1 -1
  13. package/dist/runtime/browser.js +2 -1
  14. package/dist/runtime/cache.js +3 -2
  15. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  16. package/dist/runtime/diagnostic-env-values.js +57 -0
  17. package/dist/runtime/diagnostic-env.d.ts +11 -0
  18. package/dist/runtime/diagnostic-env.js +35 -0
  19. package/dist/runtime/diagnostic-redactor.d.ts +49 -0
  20. package/dist/runtime/diagnostic-redactor.js +569 -0
  21. package/dist/runtime/egress-lease.d.ts +2 -0
  22. package/dist/runtime/egress-lease.js +4 -1
  23. package/dist/runtime/env.js +2 -1
  24. package/dist/runtime/instrumentation.js +3 -0
  25. package/dist/runtime/ocr.js +2 -1
  26. package/dist/runtime/otlp.js +5 -4
  27. package/dist/runtime/proxy-nodemaven.js +3 -2
  28. package/dist/runtime/request-options.d.ts +7 -0
  29. package/dist/runtime/request-options.js +62 -22
  30. package/dist/runtime/resolver-vendors/browser.js +0 -1
  31. package/dist/runtime/resolver.js +3 -2
  32. package/dist/runtime/stt.js +3 -2
  33. package/dist/runtime/trace.d.ts +5 -0
  34. package/dist/runtime/trace.js +115 -20
  35. package/dist/schema.d.ts +11 -0
  36. package/dist/schema.js +56 -2
  37. package/dist/server/self-test-redaction.js +8 -33
  38. package/dist/server/sensitive-values.d.ts +26 -0
  39. package/dist/server/sensitive-values.js +131 -0
  40. package/dist/server/serve-implementation.js +219 -78
  41. package/dist/server/trace-output.d.ts +2 -1
  42. package/dist/server/trace-output.js +46 -14
  43. package/dist/trace-sanitization.d.ts +4 -3
  44. package/dist/trace-sanitization.js +45 -22
  45. package/package.json +2 -1
  46. package/src/config/loader.ts +13 -12
  47. package/src/engine.ts +3 -2
  48. package/src/index.ts +2 -0
  49. package/src/lint.ts +42 -16
  50. package/src/provider.ts +1 -0
  51. package/src/runtime/browser.ts +2 -1
  52. package/src/runtime/cache.ts +3 -2
  53. package/src/runtime/diagnostic-env-values.ts +56 -0
  54. package/src/runtime/diagnostic-env.ts +55 -0
  55. package/src/runtime/diagnostic-redactor.ts +662 -0
  56. package/src/runtime/egress-lease.ts +6 -1
  57. package/src/runtime/env.ts +2 -1
  58. package/src/runtime/instrumentation.ts +3 -0
  59. package/src/runtime/ocr.ts +2 -1
  60. package/src/runtime/otlp.ts +5 -4
  61. package/src/runtime/proxy-nodemaven.ts +3 -2
  62. package/src/runtime/request-options.ts +76 -28
  63. package/src/runtime/resolver-vendors/browser.ts +0 -1
  64. package/src/runtime/resolver.ts +3 -2
  65. package/src/runtime/stt.ts +3 -2
  66. package/src/runtime/trace.ts +168 -26
  67. package/src/schema.ts +63 -2
  68. package/src/server/self-test-redaction.ts +10 -40
  69. package/src/server/sensitive-values.ts +200 -0
  70. package/src/server/serve-implementation.ts +301 -96
  71. package/src/server/trace-output.ts +77 -17
  72. package/src/trace-sanitization.ts +70 -23
@@ -1,6 +1,7 @@
1
+ import { type DiagnosticRedactor } from "../runtime/diagnostic-redactor.js";
1
2
  import { type CreateTraceContextOptions } from "../runtime/trace.js";
2
3
  import type { TraceConfig } from "../types.js";
3
4
  type EnvLike = Record<string, string | undefined>;
4
5
  /** Server-only trace output policy. Shared programmatic trace callers stay in-memory. */
5
- export declare function resolveServerTraceContextOptions(config: TraceConfig, resourceAttributes: Record<string, string>, env?: EnvLike): CreateTraceContextOptions;
6
+ export declare function resolveServerTraceContextOptions(config: TraceConfig, resourceAttributes: Record<string, string>, env?: EnvLike, redact?: DiagnosticRedactor): CreateTraceContextOptions;
6
7
  export {};
@@ -1,7 +1,9 @@
1
1
  import { isSensitiveFixtureKey, REDACTED_FIXTURE_VALUE } from "../fixture-sanitization.js";
2
+ import { redactedKeyAllocator, isRedactedKey, REDACTION_FAILED, redactDiagnosticText, } from "../runtime/diagnostic-redactor.js";
2
3
  import { OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, resolveOTLPExportOptions, resolveOTLPResourceAttributes, } from "../runtime/otlp.js";
3
4
  import { resolveTraceContextOptions, } from "../runtime/trace.js";
4
5
  import { sanitizeSpanForOutput, sanitizeSpanNameForOutput, sanitizeTraceAttributes, } from "../trace-sanitization.js";
6
+ const TRACE_RESOURCE_ATTRIBUTE_SANITIZER = Symbol.for("@apifuse/provider-sdk/runtime/trace-resource-attribute-sanitizer");
5
7
  // Warn once per environment object: process.env in production, each injected env in tests.
6
8
  const warnedEnvironments = new WeakSet();
7
9
  const warnedResourceEnvironments = new WeakSet();
@@ -36,25 +38,48 @@ function resolveServerOTLPExportOptions(config, env) {
36
38
  * environment are identifiers rather than request data: their values are kept, with control
37
39
  * characters neutralized, length bounded, and secret-named keys redacted.
38
40
  */
39
- function resolveExportResourceAttributes(requestAttributes, env) {
41
+ function resolveExportResourceAttributes(requestAttributes, env, redact) {
40
42
  const resolution = resolveOTLPResourceAttributes({}, env);
41
43
  warnDiscardedResourceAttributes(env, resolution);
42
- const operatorAttributes = Object.fromEntries(Object.entries(resolution.attributes).map(([key, value]) => [
43
- sanitizeSpanNameForOutput(key),
44
- isSensitiveFixtureKey(key) ? REDACTED_FIXTURE_VALUE : sanitizeSpanNameForOutput(value),
45
- ]));
46
- const sanitizedRequestAttributes = Object.fromEntries(Object.entries(sanitizeTraceAttributes(requestAttributes)).map(([key, value]) => [
47
- key,
48
- String(value),
49
- ]));
50
- return { ...operatorAttributes, ...sanitizedRequestAttributes };
44
+ const operatorAttributes = resolution.attributes;
45
+ const requestKeys = new Set(Object.keys(requestAttributes));
46
+ return {
47
+ // Keep the raw snapshot private until export. A credential registered after
48
+ // trace creation must be matched before any key or value is bounded.
49
+ attributes: { ...operatorAttributes, ...requestAttributes },
50
+ sanitize(attributes) {
51
+ const nextKey = redactedKeyAllocator(Object.keys(attributes));
52
+ const sanitizedOperatorAttributes = {};
53
+ const rawRequestAttributes = {};
54
+ for (const [key, value] of Object.entries(attributes)) {
55
+ const unchangedOperatorValue = !requestKeys.has(key) && operatorAttributes[key] === value;
56
+ if (unchangedOperatorValue) {
57
+ const redactedKey = isRedactedKey(key) ? key : redactDiagnosticText(key, redact);
58
+ const keyChanged = redactedKey !== key;
59
+ const sanitizedKey = keyChanged ? nextKey() : sanitizeSpanNameForOutput(key);
60
+ sanitizedOperatorAttributes[sanitizedKey] = keyChanged
61
+ ? redactedKey === REDACTION_FAILED
62
+ ? REDACTION_FAILED
63
+ : REDACTED_FIXTURE_VALUE
64
+ : isSensitiveFixtureKey(key)
65
+ ? REDACTED_FIXTURE_VALUE
66
+ : sanitizeSpanNameForOutput(value, redact);
67
+ }
68
+ else {
69
+ rawRequestAttributes[key] = value;
70
+ }
71
+ }
72
+ const sanitizedRequestAttributes = Object.fromEntries(Object.entries(sanitizeTraceAttributes(rawRequestAttributes, redact, Object.keys(sanitizedOperatorAttributes))).map(([key, value]) => [key, String(value)]));
73
+ return { ...sanitizedOperatorAttributes, ...sanitizedRequestAttributes };
74
+ },
75
+ };
51
76
  }
52
77
  /** Server-only trace output policy. Shared programmatic trace callers stay in-memory. */
53
- export function resolveServerTraceContextOptions(config, resourceAttributes, env = process.env) {
78
+ export function resolveServerTraceContextOptions(config, resourceAttributes, env = process.env, redact) {
54
79
  const resolved = resolveTraceContextOptions(config);
55
80
  const outputEnabled = config.enabled !== false && config.exporter !== "none";
56
81
  const consoleHook = outputEnabled && (config.exporter === "console" || config.exporter === "json")
57
- ? (span) => console.log(JSON.stringify(sanitizeSpanForOutput(span, resourceAttributes)))
82
+ ? (span) => console.log(JSON.stringify(sanitizeSpanForOutput(span, resourceAttributes, redact)))
58
83
  : undefined;
59
84
  const onSpan = consoleHook && resolved.onSpan
60
85
  ? (span) => {
@@ -65,14 +90,21 @@ export function resolveServerTraceContextOptions(config, resourceAttributes, env
65
90
  const exportOptions = outputEnabled && config.exporter === "otlp"
66
91
  ? resolveServerOTLPExportOptions(config, env)
67
92
  : undefined;
93
+ const exportResources = exportOptions
94
+ ? resolveExportResourceAttributes(resourceAttributes, env, redact)
95
+ : undefined;
96
+ if (exportResources) {
97
+ Object.defineProperty(exportResources.attributes, TRACE_RESOURCE_ATTRIBUTE_SANITIZER, { value: exportResources.sanitize });
98
+ }
68
99
  return {
69
100
  maxSpans: resolved.maxSpans,
70
101
  onSpan,
102
+ ...(redact ? { redact } : {}),
71
103
  ...(exportOptions
72
104
  ? {
73
105
  exportOptions,
74
- resourceAttributes: resolveExportResourceAttributes(resourceAttributes, env),
75
- sanitizeSpanForExport: (span) => sanitizeSpanForOutput(span),
106
+ resourceAttributes: exportResources?.attributes,
107
+ sanitizeSpanForExport: (span) => sanitizeSpanForOutput(span, undefined, redact),
76
108
  }
77
109
  : {}),
78
110
  };
@@ -1,5 +1,6 @@
1
+ import { type DiagnosticRedactor } from "./runtime/diagnostic-redactor.js";
1
2
  import type { TraceSpan } from "./types.js";
2
3
  /** Span names are SDK-authored identifiers; retain them while preventing log injection. */
3
- export declare function sanitizeSpanNameForOutput(value: string): string;
4
- export declare function sanitizeTraceAttributes(attributes: Record<string, unknown>): Record<string, string | number | boolean>;
5
- export declare function sanitizeSpanForOutput(span: TraceSpan, additionalAttributes?: Record<string, string>): TraceSpan;
4
+ export declare function sanitizeSpanNameForOutput(value: string, redact?: DiagnosticRedactor): string;
5
+ export declare function sanitizeTraceAttributes(attributes: Record<string, unknown>, redact?: DiagnosticRedactor, reservedKeys?: Iterable<string>): Record<string, string | number | boolean>;
6
+ export declare function sanitizeSpanForOutput(span: TraceSpan, additionalAttributes?: Record<string, string>, redact?: DiagnosticRedactor): TraceSpan;
@@ -1,45 +1,68 @@
1
1
  import { encodeDiagnosticControls, isSensitiveFixtureKey, REDACTED_FIXTURE_VALUE, sanitizeDiagnosticText, } from "./fixture-sanitization.js";
2
+ import { diagnosticStructuredRedactor, redactDiagnosticAttributeKey, copyDiagnosticAttributeTypes, isDiagnosticBigintAttribute, redactedKeyAllocator, isRedactedKey, redactCriticalDiagnosticText, diagnosticAttributeRedactor, REDACTION_FAILED, redactDiagnosticText, } from "./runtime/diagnostic-redactor.js";
2
3
  const MAX_TRACE_TEXT_LENGTH = 300;
3
- function sanitizeTraceText(value) {
4
- const encoded = sanitizeDiagnosticText(value);
4
+ function sanitizeTraceText(value, redact) {
5
+ const encoded = sanitizeDiagnosticText(redactDiagnosticText(value, redact));
5
6
  return encoded.length > MAX_TRACE_TEXT_LENGTH
6
7
  ? `${encoded.slice(0, MAX_TRACE_TEXT_LENGTH)}… [truncated]`
7
8
  : encoded;
8
9
  }
9
10
  /** Span names are SDK-authored identifiers; retain them while preventing log injection. */
10
- export function sanitizeSpanNameForOutput(value) {
11
- const encoded = encodeDiagnosticControls(value);
11
+ export function sanitizeSpanNameForOutput(value, redact) {
12
+ const encoded = encodeDiagnosticControls(redactDiagnosticText(value, redact));
12
13
  return encoded.length > MAX_TRACE_TEXT_LENGTH
13
14
  ? `${encoded.slice(0, MAX_TRACE_TEXT_LENGTH)}… [truncated]`
14
15
  : encoded;
15
16
  }
16
- export function sanitizeTraceAttributes(attributes) {
17
- return Object.fromEntries(Object.entries(attributes).map(([key, value]) => [
18
- sanitizeTraceText(key),
19
- isSensitiveFixtureKey(key)
20
- ? REDACTED_FIXTURE_VALUE
21
- : typeof value === "string"
22
- ? sanitizeTraceText(value)
23
- : typeof value === "number" || typeof value === "boolean"
24
- ? value
25
- : sanitizeTraceText(String(value)),
26
- ]));
17
+ export function sanitizeTraceAttributes(attributes, redact, reservedKeys = []) {
18
+ const nextKey = redactedKeyAllocator([...Object.keys(attributes), ...reservedKeys]);
19
+ return copyDiagnosticAttributeTypes(attributes, Object.fromEntries(Object.entries(attributes).map(([key, value]) => {
20
+ const redactedKey = isRedactedKey(key) ? key : redactDiagnosticAttributeKey(key, redact);
21
+ const keyChanged = redactedKey !== key;
22
+ const sanitizedKey = keyChanged ? nextKey() : sanitizeTraceText(key);
23
+ const valueRedactor = typeof value === "bigint" || isDiagnosticBigintAttribute(attributes, key)
24
+ ? diagnosticStructuredRedactor(redact)
25
+ : diagnosticAttributeRedactor(key, redact);
26
+ const sanitizedValue = keyChanged
27
+ ? redactedKey === REDACTION_FAILED
28
+ ? REDACTION_FAILED
29
+ : REDACTED_FIXTURE_VALUE
30
+ : isSensitiveFixtureKey(key)
31
+ ? REDACTED_FIXTURE_VALUE
32
+ : typeof value === "bigint" || isDiagnosticBigintAttribute(attributes, key)
33
+ ? String(value).length < 8
34
+ ? String(value)
35
+ : sanitizeTraceText(String(value), valueRedactor)
36
+ : typeof value === "string"
37
+ ? sanitizeTraceText(value, valueRedactor)
38
+ : typeof value === "number" || typeof value === "boolean"
39
+ ? (() => {
40
+ if (String(value).length < 8)
41
+ return value;
42
+ const redacted = redactDiagnosticText(String(value), diagnosticStructuredRedactor(valueRedactor));
43
+ return redacted === String(value) ? value : sanitizeTraceText(redacted);
44
+ })()
45
+ : sanitizeTraceText(String(value), valueRedactor);
46
+ return [sanitizedKey, sanitizedValue];
47
+ })));
27
48
  }
28
- export function sanitizeSpanForOutput(span, additionalAttributes) {
49
+ export function sanitizeSpanForOutput(span, additionalAttributes, redact) {
29
50
  // Keep this schema explicit so future fields are not silently added to a
30
51
  // process output path before their trust boundary has been reviewed.
31
52
  return {
32
- id: span.id,
33
- name: sanitizeSpanNameForOutput(span.name),
53
+ id: redactCriticalDiagnosticText(span.id, redact),
54
+ name: sanitizeSpanNameForOutput(redactCriticalDiagnosticText(span.name, redact)),
34
55
  startedAt: span.startedAt,
35
56
  endedAt: span.endedAt,
36
57
  duration_ms: span.duration_ms,
37
58
  status: span.status,
38
- attributes: sanitizeTraceAttributes({
59
+ attributes: sanitizeTraceAttributes(copyDiagnosticAttributeTypes(span.attributes, {
39
60
  ...span.attributes,
40
61
  ...(additionalAttributes ?? {}),
41
- }),
42
- ...(span.error !== undefined ? { error: sanitizeTraceText(span.error) } : {}),
43
- ...(span.parentId !== undefined ? { parentId: span.parentId } : {}),
62
+ }), redact),
63
+ ...(span.error !== undefined ? { error: sanitizeTraceText(span.error, redact) } : {}),
64
+ ...(span.parentId !== undefined
65
+ ? { parentId: redactCriticalDiagnosticText(span.parentId, redact) }
66
+ : {}),
44
67
  };
45
68
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.59",
2
+ "version": "2.2.0-beta.60",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -110,6 +110,7 @@
110
110
  "format": "biome format --write",
111
111
  "type-check": "tsc --noEmit",
112
112
  "test": "bun test",
113
+ "bench:redaction": "bun scripts/benchmark-request-redaction.ts",
113
114
  "check": "bun run lint && bun run type-check && bun run lint:deprecated && bun run lint:test-typesafety && bun run build",
114
115
  "api:update": "bun run build && bun scripts/api-reports.ts update",
115
116
  "api:check": "bun run build && bun scripts/api-reports.ts check",
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "../runtime/diagnostic-env.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import { createRequire } from "node:module";
@@ -256,8 +257,8 @@ const SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS = 10_000;
256
257
 
257
258
  function redisUrlFromEnv(): string | undefined {
258
259
  return (
259
- process.env.APIFUSE__PROVIDER__CACHE_REDIS_URL?.trim() ||
260
- process.env[REDIS_URL_ENV]?.trim() ||
260
+ readDiagnosticEnv(PROVIDER_CACHE_REDIS_URL_ENV)?.trim() ||
261
+ readDiagnosticEnv(REDIS_URL_ENV)?.trim() ||
261
262
  undefined
262
263
  );
263
264
  }
@@ -268,9 +269,9 @@ export function providerCacheRedisUrlFromEnv(): string | undefined {
268
269
 
269
270
  export function providerStateRedisUrlFromEnv(): string | undefined {
270
271
  return (
271
- process.env[PROVIDER_STATE_REDIS_URL_ENV]?.trim() ||
272
- process.env[PROVIDER_CACHE_REDIS_URL_ENV]?.trim() ||
273
- process.env[REDIS_URL_ENV]?.trim() ||
272
+ readDiagnosticEnv(PROVIDER_STATE_REDIS_URL_ENV)?.trim() ||
273
+ readDiagnosticEnv(PROVIDER_CACHE_REDIS_URL_ENV)?.trim() ||
274
+ readDiagnosticEnv(REDIS_URL_ENV)?.trim() ||
274
275
  undefined
275
276
  );
276
277
  }
@@ -693,12 +694,12 @@ export async function resolveWithVendor(
693
694
  const startedAt = Date.now();
694
695
  const username = (
695
696
  context.credentials === undefined
696
- ? process.env[NODEMAVEN_USERNAME_ENV]
697
+ ? readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)
697
698
  : context.credentials[NODEMAVEN_USERNAME_ENV]
698
699
  )?.trim();
699
700
  const password = (
700
701
  context.credentials === undefined
701
- ? process.env[NODEMAVEN_PASSWORD_ENV]
702
+ ? readDiagnosticEnv(NODEMAVEN_PASSWORD_ENV)
702
703
  : context.credentials[NODEMAVEN_PASSWORD_ENV]
703
704
  )?.trim();
704
705
  const filter =
@@ -749,7 +750,7 @@ export async function resolveWithVendor(
749
750
  // smartproxy allocation-style vendor.
750
751
  const appKey = (
751
752
  context.credentials === undefined
752
- ? process.env[SMARTPROXY_APP_KEY_ENV]
753
+ ? readDiagnosticEnv(SMARTPROXY_APP_KEY_ENV)
753
754
  : context.credentials[SMARTPROXY_APP_KEY_ENV]
754
755
  )?.trim();
755
756
  if (!appKey) {
@@ -838,18 +839,18 @@ function vendorHasCredentials(
838
839
  if (vendor === "nodemaven") {
839
840
  return Boolean(
840
841
  (credentials === undefined
841
- ? process.env[NODEMAVEN_USERNAME_ENV]
842
+ ? readDiagnosticEnv(NODEMAVEN_USERNAME_ENV)
842
843
  : credentials[NODEMAVEN_USERNAME_ENV]
843
844
  )?.trim() &&
844
845
  (credentials === undefined
845
- ? process.env[NODEMAVEN_PASSWORD_ENV]
846
+ ? readDiagnosticEnv(NODEMAVEN_PASSWORD_ENV)
846
847
  : credentials[NODEMAVEN_PASSWORD_ENV]
847
848
  )?.trim(),
848
849
  );
849
850
  }
850
851
  return Boolean(
851
852
  (credentials === undefined
852
- ? process.env[SMARTPROXY_APP_KEY_ENV]
853
+ ? readDiagnosticEnv(SMARTPROXY_APP_KEY_ENV)
853
854
  : credentials[SMARTPROXY_APP_KEY_ENV]
854
855
  )?.trim(),
855
856
  );
@@ -1757,7 +1758,7 @@ function markSmartproxyCacheInvalidated(options: ProxyResolutionOptions = {}): s
1757
1758
  const lifetimeMinutes = resolveSmartproxyLifetime(policy);
1758
1759
  const appKey = (
1759
1760
  options.engineCredentials === undefined
1760
- ? process.env[SMARTPROXY_APP_KEY_ENV]
1761
+ ? readDiagnosticEnv(SMARTPROXY_APP_KEY_ENV)
1761
1762
  : options.engineCredentials[SMARTPROXY_APP_KEY_ENV]
1762
1763
  )?.trim();
1763
1764
  if (!appKey) return undefined;
package/src/engine.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./runtime/diagnostic-env.js";
1
2
  import { ProviderError } from "./errors.js";
2
3
  import {
3
4
  APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
@@ -160,7 +161,7 @@ export function readEngineProxyCredentials(
160
161
  ): Readonly<Record<string, string>> {
161
162
  return Object.fromEntries(
162
163
  ENGINE_OWNED_PROXY_CREDENTIAL_ENV_NAMES.flatMap((name) => {
163
- const value = environment[name]?.trim();
164
+ const value = readDiagnosticEnv(name, environment)?.trim();
164
165
  return value ? [[name, value] as const] : [];
165
166
  }),
166
167
  );
@@ -174,7 +175,7 @@ export function createProviderEnvironment(
174
175
  return Object.fromEntries(
175
176
  declaredNames.flatMap((name) => {
176
177
  if (isEngineOwnedEnvName(name)) return [];
177
- const value = environment[name];
178
+ const value = readDiagnosticEnv(name, environment);
178
179
  return value === undefined ? [] : [[name, value] as const];
179
180
  }),
180
181
  );
package/src/index.ts CHANGED
@@ -275,6 +275,7 @@ export {
275
275
  type Span,
276
276
  type TraceContext,
277
277
  } from "./runtime/trace.js";
278
+ export type { DiagnosticRedactor } from "./runtime/diagnostic-redactor.js";
278
279
  export {
279
280
  APIFUSE_CONTENT_PROVENANCE_META_KEY,
280
281
  APIFUSE_CONTENT_TRUST_META_KEY,
@@ -287,6 +288,7 @@ export {
287
288
  field,
288
289
  fields,
289
290
  isSensitiveSchema,
291
+ publicField,
290
292
  redactPayload,
291
293
  type SensitiveFieldKind,
292
294
  type SensitiveFieldOptions,
package/src/lint.ts CHANGED
@@ -550,18 +550,29 @@ function uniqueFields(fields: string[]): string[] {
550
550
  return Array.from(new Set(fields));
551
551
  }
552
552
 
553
- function isSensitiveSchema(schema: unknown): boolean {
554
- if (!schema || typeof schema !== "object" || !("meta" in schema)) {
555
- return false;
553
+ function readSensitivityDeclaration(
554
+ schema: unknown,
555
+ seen = new Set<unknown>(),
556
+ ): boolean | undefined {
557
+ // Declarations sit on the leaf; same-path wrappers such as .optional(),
558
+ // .nullable(), .pipe() or z.lazy() carry no metadata of their own, so look
559
+ // through them.
560
+ if (!isSchema(schema) || seen.has(schema)) return undefined;
561
+ seen.add(schema);
562
+ const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_SENSITIVE_META_KEY);
563
+ if (typeof value === "boolean") return value;
564
+ const def = getSchemaDef(schema);
565
+ const children =
566
+ def.type === "pipe"
567
+ ? [def.in, def.out]
568
+ : def.type === "lazy" && typeof def.getter === "function"
569
+ ? [def.getter()]
570
+ : [def.innerType];
571
+ for (const child of children) {
572
+ const declaration = readSensitivityDeclaration(child, seen);
573
+ if (declaration !== undefined) return declaration;
556
574
  }
557
- const meta = schema.meta;
558
- if (typeof meta !== "function") return false;
559
- const metadata = meta.call(schema);
560
- return (
561
- !!metadata &&
562
- typeof metadata === "object" &&
563
- Reflect.get(metadata, APIFUSE_SENSITIVE_META_KEY) === true
564
- );
575
+ return undefined;
565
576
  }
566
577
 
567
578
  function getSchemaMetadata(schema: SchemaLike): Record<string, unknown> {
@@ -591,9 +602,24 @@ const SENSITIVE_FIELD_NAMES = new Set([
591
602
  "paymenturl",
592
603
  ]);
593
604
 
605
+ // Only phone-shaped keys can hold reviewed public data (facility phones); the
606
+ // rest are credentials, which a public declaration must not launder.
607
+ const PUBLIC_DECLARABLE_FIELD_NAMES = new Set(["phone", "phonenumber"]);
608
+
609
+ function normalizeFieldName(name: string): string {
610
+ return name.toLowerCase().replace(/[-_\s]/g, "");
611
+ }
612
+
594
613
  function isSensitiveFieldName(name: string): boolean {
595
- const normalized = name.toLowerCase().replace(/[-_\s]/g, "");
596
- return SENSITIVE_FIELD_NAMES.has(normalized);
614
+ return SENSITIVE_FIELD_NAMES.has(normalizeFieldName(name));
615
+ }
616
+
617
+ function isDeclaredSensitiveField(key: string, schema: unknown): boolean {
618
+ const declaration = readSensitivityDeclaration(schema);
619
+ return (
620
+ declaration === true ||
621
+ (declaration === false && PUBLIC_DECLARABLE_FIELD_NAMES.has(normalizeFieldName(key)))
622
+ );
597
623
  }
598
624
 
599
625
  function collectUnmarkedSensitiveFields(
@@ -608,7 +634,7 @@ function collectUnmarkedSensitiveFields(
608
634
  const out: string[] = [];
609
635
  for (const [key, child] of Object.entries(getObjectShape(schema))) {
610
636
  const childPath = basePath ? `${basePath}.${key}` : key;
611
- if (isSensitiveFieldName(key) && !isSensitiveSchema(child)) {
637
+ if (isSensitiveFieldName(key) && !isDeclaredSensitiveField(key, child)) {
612
638
  out.push(childPath);
613
639
  }
614
640
  out.push(...collectUnmarkedSensitiveFields(child, childPath, seen));
@@ -1401,7 +1427,7 @@ export function lintOperation(op: {
1401
1427
  rule: "sensitive-field-unmarked",
1402
1428
  level: "warn",
1403
1429
  field,
1404
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
1430
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
1405
1431
  });
1406
1432
  }
1407
1433
 
@@ -1410,7 +1436,7 @@ export function lintOperation(op: {
1410
1436
  rule: "sensitive-field-unmarked",
1411
1437
  level: "warn",
1412
1438
  field,
1413
- message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
1439
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*() or sensitive(...); publicField(...) is accepted only for reviewed public phone-shaped fields.`,
1414
1440
  });
1415
1441
  }
1416
1442
 
package/src/provider.ts CHANGED
@@ -179,6 +179,7 @@ export {
179
179
  field,
180
180
  fields,
181
181
  isSensitiveSchema,
182
+ publicField,
182
183
  redactPayload,
183
184
  type SensitiveFieldKind,
184
185
  type SensitiveFieldOptions,
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { createRequire } from "node:module";
2
3
  import type { CDPSession, Frame, LaunchOptions, Locator, Page, Request, Route } from "playwright";
3
4
 
@@ -495,7 +496,7 @@ type SupportedBrowserClient = {
495
496
  };
496
497
 
497
498
  function getDefaultCdpPoolUrl(env = process.env): string | undefined {
498
- return env.APIFUSE__CDP_POOL__URL;
499
+ return readDiagnosticEnv("APIFUSE__CDP_POOL__URL", env);
499
500
  }
500
501
 
501
502
  async function importOptionalModule<T extends object>(moduleName: string): Promise<T> {
@@ -1,3 +1,4 @@
1
+ import { readDiagnosticEnv } from "./diagnostic-env.js";
1
2
  import { createHash, createHmac } from "node:crypto";
2
3
 
3
4
  import { providerCacheRedisUrlFromEnv } from "../config/loader.js";
@@ -362,7 +363,7 @@ async function withRedisFallback<T>(operation: () => Promise<T>): Promise<T | un
362
363
 
363
364
  export function createProviderCache(options: ProviderCacheOptions): ProviderCache {
364
365
  const redisUrl = options.redisUrl ?? providerCacheRedisUrlFromEnv();
365
- const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
366
+ const configuredPepper = readDiagnosticEnv(APIFUSE__CACHE__KEY_PEPPER_ENV);
366
367
  const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
367
368
  const backend = getSharedBackend(redisUrl);
368
369
  const memoryMaxEntries = Math.max(1, options.memoryMaxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES);
@@ -599,7 +600,7 @@ export function isProviderCacheBypassed(cache: ProviderCache): boolean {
599
600
  export function createBypassProviderCache(
600
601
  options: Pick<ProviderCacheOptions, "providerId">,
601
602
  ): ProviderCache {
602
- const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
603
+ const configuredPepper = readDiagnosticEnv(APIFUSE__CACHE__KEY_PEPPER_ENV);
603
604
  const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
604
605
  const events: ProviderCacheLookupMeta[] = [];
605
606
  const secretScopedKeys = new Set<string>();
@@ -0,0 +1,56 @@
1
+ export function resolvedStringVariants(value: string | undefined): string[] {
2
+ if (value === undefined) return [];
3
+ return [...new Set([value, value.trim()])].filter((candidate) => candidate.length > 0);
4
+ }
5
+
6
+ function decodedComponent(value: string): string {
7
+ try {
8
+ return decodeURIComponent(value);
9
+ } catch {
10
+ return value;
11
+ }
12
+ }
13
+
14
+ export function urlCredentialComponents(
15
+ value: string,
16
+ options: { includePathAndQuery: boolean },
17
+ ): string[] {
18
+ let url: URL;
19
+ try {
20
+ url = new URL(value.trim());
21
+ } catch {
22
+ return [];
23
+ }
24
+ const username = decodedComponent(url.username);
25
+ const password = decodedComponent(url.password);
26
+ const components = [username, password, username && password ? `${username}:${password}` : ""];
27
+ if (options.includePathAndQuery) {
28
+ for (const segment of url.pathname.split("/")) {
29
+ if (segment) components.push(decodedComponent(segment));
30
+ }
31
+ for (const [key, entryValue] of url.searchParams) {
32
+ if (key) components.push(key);
33
+ if (entryValue) {
34
+ components.push(entryValue, `${key}=${entryValue}`);
35
+ }
36
+ }
37
+ }
38
+ return [...new Set(components.flatMap((component) => resolvedStringVariants(component)))];
39
+ }
40
+
41
+ export function otlpHeaderCredentialValues(value: string): string[] {
42
+ const variants = resolvedStringVariants(value);
43
+ for (const member of value.split(",")) {
44
+ const separator = member.indexOf("=");
45
+ if (separator <= 0) continue;
46
+ const headerValue = decodedComponent(member.slice(separator + 1).trim());
47
+ variants.push(...resolvedStringVariants(headerValue));
48
+ const headerName = decodedComponent(member.slice(0, separator).trim()).toLowerCase();
49
+ if (headerName === "authorization" || headerName === "proxy-authorization") {
50
+ // A transport may echo just the credential without its HTTP auth scheme.
51
+ const credential = /^(?:Bearer|Basic)\s+(.+)$/i.exec(headerValue)?.[1];
52
+ if (credential) variants.push(...resolvedStringVariants(credential));
53
+ }
54
+ }
55
+ return [...new Set(variants)];
56
+ }
@@ -0,0 +1,55 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ import {
4
+ resolvedStringVariants,
5
+ urlCredentialComponents,
6
+ otlpHeaderCredentialValues,
7
+ } from "./diagnostic-env-values.js";
8
+ import {
9
+ type CompiledDiagnosticSensitiveValues,
10
+ registerProcessDiagnosticValues,
11
+ suppressProcessDiagnosticValues,
12
+ withAppDiagnosticFallback,
13
+ } from "./diagnostic-redactor.js";
14
+
15
+ type EnvObserver = (name: string, value: string) => void;
16
+ export interface DiagnosticEnvScope {
17
+ observe: EnvObserver;
18
+ finished: boolean;
19
+ staticValues?: CompiledDiagnosticSensitiveValues;
20
+ }
21
+ const observers = new AsyncLocalStorage<DiagnosticEnvScope>();
22
+
23
+ /** Resolve and register atomically, before any consumer can use a live env credential. */
24
+ export function readDiagnosticEnv(
25
+ name: string,
26
+ env: Readonly<Record<string, string | undefined>> = process.env,
27
+ ): string | undefined {
28
+ const value = env[name];
29
+ if (value !== undefined) {
30
+ const scope = observers.getStore();
31
+ if (scope && !scope.finished) scope.observe(name, value);
32
+ else {
33
+ // ALS survives the response. Finished requests register into the app's
34
+ // inherited fallback lifetime, never a dead request's private inventory.
35
+ if (Buffer.byteLength(value) > 64 * 1024) suppressProcessDiagnosticValues();
36
+ else
37
+ registerProcessDiagnosticValues([
38
+ ...resolvedStringVariants(value),
39
+ ...urlCredentialComponents(value, {
40
+ includePathAndQuery: name === "APIFUSE__CDP_POOL__URL",
41
+ }),
42
+ ...(name === "OTEL_EXPORTER_OTLP_HEADERS" || name === "OTEL_EXPORTER_OTLP_TRACES_HEADERS"
43
+ ? otlpHeaderCredentialValues(value)
44
+ : []),
45
+ ]);
46
+ }
47
+ }
48
+ return value;
49
+ }
50
+
51
+ export function withDiagnosticEnv<T>(observer: EnvObserver | DiagnosticEnvScope, fn: () => T): T {
52
+ const scope = typeof observer === "function" ? { observe: observer, finished: false } : observer;
53
+ const run = () => observers.run(scope, fn);
54
+ return scope.staticValues ? withAppDiagnosticFallback(scope.staticValues, run) : run();
55
+ }