@ontrails/core 1.0.0-beta.0

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 (216) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +179 -0
  6. package/dist/adapters.d.ts +39 -0
  7. package/dist/adapters.d.ts.map +1 -0
  8. package/dist/adapters.js +2 -0
  9. package/dist/adapters.js.map +1 -0
  10. package/dist/blob-ref.d.ts +20 -0
  11. package/dist/blob-ref.d.ts.map +1 -0
  12. package/dist/blob-ref.js +22 -0
  13. package/dist/blob-ref.js.map +1 -0
  14. package/dist/branded.d.ts +36 -0
  15. package/dist/branded.d.ts.map +1 -0
  16. package/dist/branded.js +89 -0
  17. package/dist/branded.js.map +1 -0
  18. package/dist/collections.d.ts +31 -0
  19. package/dist/collections.d.ts.map +1 -0
  20. package/dist/collections.js +60 -0
  21. package/dist/collections.js.map +1 -0
  22. package/dist/context.d.ts +10 -0
  23. package/dist/context.d.ts.map +1 -0
  24. package/dist/context.js +15 -0
  25. package/dist/context.js.map +1 -0
  26. package/dist/derive.d.ts +33 -0
  27. package/dist/derive.d.ts.map +1 -0
  28. package/dist/derive.js +122 -0
  29. package/dist/derive.js.map +1 -0
  30. package/dist/errors.d.ts +83 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +142 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/event.d.ts +45 -0
  35. package/dist/event.d.ts.map +1 -0
  36. package/dist/event.js +17 -0
  37. package/dist/event.js.map +1 -0
  38. package/dist/fetch.d.ts +15 -0
  39. package/dist/fetch.d.ts.map +1 -0
  40. package/dist/fetch.js +102 -0
  41. package/dist/fetch.js.map +1 -0
  42. package/dist/guards.d.ts +17 -0
  43. package/dist/guards.d.ts.map +1 -0
  44. package/dist/guards.js +25 -0
  45. package/dist/guards.js.map +1 -0
  46. package/dist/health.d.ts +18 -0
  47. package/dist/health.d.ts.map +1 -0
  48. package/dist/health.js +5 -0
  49. package/dist/health.js.map +1 -0
  50. package/dist/hike.d.ts +36 -0
  51. package/dist/hike.d.ts.map +1 -0
  52. package/dist/hike.js +20 -0
  53. package/dist/hike.js.map +1 -0
  54. package/dist/index.d.ts +34 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +38 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/job.d.ts +24 -0
  59. package/dist/job.d.ts.map +1 -0
  60. package/dist/job.js +17 -0
  61. package/dist/job.js.map +1 -0
  62. package/dist/layer.d.ts +17 -0
  63. package/dist/layer.d.ts.map +1 -0
  64. package/dist/layer.js +21 -0
  65. package/dist/layer.js.map +1 -0
  66. package/dist/path-security.d.ts +28 -0
  67. package/dist/path-security.d.ts.map +1 -0
  68. package/dist/path-security.js +63 -0
  69. package/dist/path-security.js.map +1 -0
  70. package/dist/patterns/bulk.d.ts +15 -0
  71. package/dist/patterns/bulk.d.ts.map +1 -0
  72. package/dist/patterns/bulk.js +14 -0
  73. package/dist/patterns/bulk.js.map +1 -0
  74. package/dist/patterns/change.d.ts +10 -0
  75. package/dist/patterns/change.d.ts.map +1 -0
  76. package/dist/patterns/change.js +10 -0
  77. package/dist/patterns/change.js.map +1 -0
  78. package/dist/patterns/date-range.d.ts +10 -0
  79. package/dist/patterns/date-range.d.ts.map +1 -0
  80. package/dist/patterns/date-range.js +10 -0
  81. package/dist/patterns/date-range.js.map +1 -0
  82. package/dist/patterns/index.d.ts +9 -0
  83. package/dist/patterns/index.d.ts.map +1 -0
  84. package/dist/patterns/index.js +9 -0
  85. package/dist/patterns/index.js.map +1 -0
  86. package/dist/patterns/pagination.d.ts +18 -0
  87. package/dist/patterns/pagination.d.ts.map +1 -0
  88. package/dist/patterns/pagination.js +18 -0
  89. package/dist/patterns/pagination.js.map +1 -0
  90. package/dist/patterns/progress.d.ts +11 -0
  91. package/dist/patterns/progress.d.ts.map +1 -0
  92. package/dist/patterns/progress.js +11 -0
  93. package/dist/patterns/progress.js.map +1 -0
  94. package/dist/patterns/sorting.d.ts +13 -0
  95. package/dist/patterns/sorting.d.ts.map +1 -0
  96. package/dist/patterns/sorting.js +10 -0
  97. package/dist/patterns/sorting.js.map +1 -0
  98. package/dist/patterns/status.d.ts +15 -0
  99. package/dist/patterns/status.d.ts.map +1 -0
  100. package/dist/patterns/status.js +9 -0
  101. package/dist/patterns/status.js.map +1 -0
  102. package/dist/patterns/timestamps.d.ts +10 -0
  103. package/dist/patterns/timestamps.d.ts.map +1 -0
  104. package/dist/patterns/timestamps.js +10 -0
  105. package/dist/patterns/timestamps.js.map +1 -0
  106. package/dist/redaction/index.d.ts +4 -0
  107. package/dist/redaction/index.d.ts.map +1 -0
  108. package/dist/redaction/index.js +3 -0
  109. package/dist/redaction/index.js.map +1 -0
  110. package/dist/redaction/patterns.d.ts +9 -0
  111. package/dist/redaction/patterns.d.ts.map +1 -0
  112. package/dist/redaction/patterns.js +39 -0
  113. package/dist/redaction/patterns.js.map +1 -0
  114. package/dist/redaction/redactor.d.ts +27 -0
  115. package/dist/redaction/redactor.d.ts.map +1 -0
  116. package/dist/redaction/redactor.js +89 -0
  117. package/dist/redaction/redactor.js.map +1 -0
  118. package/dist/resilience.d.ts +34 -0
  119. package/dist/resilience.d.ts.map +1 -0
  120. package/dist/resilience.js +164 -0
  121. package/dist/resilience.js.map +1 -0
  122. package/dist/result.d.ts +57 -0
  123. package/dist/result.d.ts.map +1 -0
  124. package/dist/result.js +145 -0
  125. package/dist/result.js.map +1 -0
  126. package/dist/serialization.d.ts +27 -0
  127. package/dist/serialization.d.ts.map +1 -0
  128. package/dist/serialization.js +115 -0
  129. package/dist/serialization.js.map +1 -0
  130. package/dist/topo.d.ts +18 -0
  131. package/dist/topo.d.ts.map +1 -0
  132. package/dist/topo.js +74 -0
  133. package/dist/topo.js.map +1 -0
  134. package/dist/trail.d.ts +83 -0
  135. package/dist/trail.d.ts.map +1 -0
  136. package/dist/trail.js +16 -0
  137. package/dist/trail.js.map +1 -0
  138. package/dist/types.d.ts +46 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +2 -0
  141. package/dist/types.js.map +1 -0
  142. package/dist/validate-topo.d.ts +24 -0
  143. package/dist/validate-topo.d.ts.map +1 -0
  144. package/dist/validate-topo.js +108 -0
  145. package/dist/validate-topo.js.map +1 -0
  146. package/dist/validation.d.ts +27 -0
  147. package/dist/validation.d.ts.map +1 -0
  148. package/dist/validation.js +134 -0
  149. package/dist/validation.js.map +1 -0
  150. package/dist/workspace.d.ts +25 -0
  151. package/dist/workspace.d.ts.map +1 -0
  152. package/dist/workspace.js +57 -0
  153. package/dist/workspace.js.map +1 -0
  154. package/package.json +21 -0
  155. package/src/__tests__/blob-ref.test.ts +103 -0
  156. package/src/__tests__/branded.test.ts +148 -0
  157. package/src/__tests__/collections.test.ts +126 -0
  158. package/src/__tests__/context.test.ts +66 -0
  159. package/src/__tests__/derive.test.ts +159 -0
  160. package/src/__tests__/errors.test.ts +309 -0
  161. package/src/__tests__/event.test.ts +82 -0
  162. package/src/__tests__/fetch.test.ts +217 -0
  163. package/src/__tests__/guards.test.ts +102 -0
  164. package/src/__tests__/hike.test.ts +117 -0
  165. package/src/__tests__/job.test.ts +98 -0
  166. package/src/__tests__/layer.test.ts +224 -0
  167. package/src/__tests__/path-security.test.ts +114 -0
  168. package/src/__tests__/patterns.test.ts +273 -0
  169. package/src/__tests__/redaction.test.ts +244 -0
  170. package/src/__tests__/resilience.test.ts +246 -0
  171. package/src/__tests__/result.test.ts +155 -0
  172. package/src/__tests__/serialization.test.ts +236 -0
  173. package/src/__tests__/topo.test.ts +184 -0
  174. package/src/__tests__/trail.test.ts +179 -0
  175. package/src/__tests__/validate-topo.test.ts +201 -0
  176. package/src/__tests__/validation.test.ts +283 -0
  177. package/src/__tests__/workspace.test.ts +183 -0
  178. package/src/adapters.ts +68 -0
  179. package/src/blob-ref.ts +39 -0
  180. package/src/branded.ts +135 -0
  181. package/src/collections.ts +99 -0
  182. package/src/context.ts +18 -0
  183. package/src/derive.ts +223 -0
  184. package/src/errors.ts +196 -0
  185. package/src/event.ts +77 -0
  186. package/src/fetch.ts +138 -0
  187. package/src/guards.ts +37 -0
  188. package/src/health.ts +23 -0
  189. package/src/hike.ts +77 -0
  190. package/src/index.ts +158 -0
  191. package/src/job.ts +20 -0
  192. package/src/layer.ts +44 -0
  193. package/src/path-security.ts +90 -0
  194. package/src/patterns/bulk.ts +16 -0
  195. package/src/patterns/change.ts +12 -0
  196. package/src/patterns/date-range.ts +12 -0
  197. package/src/patterns/index.ts +8 -0
  198. package/src/patterns/pagination.ts +22 -0
  199. package/src/patterns/progress.ts +13 -0
  200. package/src/patterns/sorting.ts +14 -0
  201. package/src/patterns/status.ts +11 -0
  202. package/src/patterns/timestamps.ts +12 -0
  203. package/src/redaction/index.ts +3 -0
  204. package/src/redaction/patterns.ts +47 -0
  205. package/src/redaction/redactor.ts +178 -0
  206. package/src/resilience.ts +234 -0
  207. package/src/result.ts +180 -0
  208. package/src/serialization.ts +183 -0
  209. package/src/topo.ts +123 -0
  210. package/src/trail.ts +130 -0
  211. package/src/types.ts +58 -0
  212. package/src/validate-topo.ts +151 -0
  213. package/src/validation.ts +182 -0
  214. package/src/workspace.ts +77 -0
  215. package/tsconfig.json +9 -0
  216. package/tsconfig.tsbuildinfo +1 -0
package/src/job.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+
3
+ import { progressFields } from './patterns/progress.js';
4
+ import { statusFields } from './patterns/status.js';
5
+
6
+ /**
7
+ * Proof: job-style output schema composes from existing pattern helpers.
8
+ * No dedicated `kind: "job"` needed — regular trails with status+progress output work.
9
+ */
10
+ export const jobOutputSchema = z.object({
11
+ ...progressFields().shape,
12
+ ...statusFields().shape,
13
+ completedAt: z.string().optional(),
14
+ error: z.string().optional(),
15
+ jobId: z.string(),
16
+ result: z.unknown().optional(),
17
+ startedAt: z.string().optional(),
18
+ });
19
+
20
+ export type JobOutput = z.infer<typeof jobOutputSchema>;
package/src/layer.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Trail } from './trail.js';
2
+ import type { Implementation } from './types.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Layer interface
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** A composable middleware that wraps trail implementations */
9
+ export interface Layer {
10
+ readonly name: string;
11
+ readonly description?: string | undefined;
12
+
13
+ /** Wrap a trail's implementation, returning a new implementation */
14
+ wrap<I, O>(
15
+ trail: Trail<I, O>,
16
+ implementation: Implementation<I, O>
17
+ ): Implementation<I, O>;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Composition
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Apply layers outermost-first: layers[0] wraps layers[1] wraps ... wraps
26
+ * the base implementation.
27
+ *
28
+ * An empty layers array returns the implementation unchanged.
29
+ */
30
+ export const composeLayers = <I, O>(
31
+ layers: readonly Layer[],
32
+ trail: Trail<I, O>,
33
+ implementation: Implementation<I, O>
34
+ ): Implementation<I, O> => {
35
+ // Fold right so layers[0] is the outermost wrapper
36
+ let result = implementation;
37
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
38
+ const layer = layers[i];
39
+ if (layer) {
40
+ result = layer.wrap(trail, result);
41
+ }
42
+ }
43
+ return result;
44
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Path security utilities for preventing path traversal attacks.
3
+ *
4
+ * All functions are runtime-agnostic (Node / Bun compatible).
5
+ */
6
+
7
+ import { resolve, relative, normalize, isAbsolute } from 'node:path';
8
+
9
+ import { PermissionError } from './errors.js';
10
+ import { Result } from './result.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Internal
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Returns true when `target` is equal to or a descendant of `base`. */
17
+ const isWithin = (base: string, target: string): boolean => {
18
+ const rel = relative(base, target);
19
+ // Empty string means they are the same directory.
20
+ // A relative path starting with ".." means it escapes.
21
+ // An absolute path means a completely different tree.
22
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Public API
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Resolves `userPath` relative to `basePath` and ensures it stays within
31
+ * the base directory. Returns the resolved absolute path on success, or a
32
+ * `PermissionError` if the path escapes.
33
+ *
34
+ * Uses lexical path comparison (not `realpath`). Does not follow symlinks —
35
+ * if an attacker can create symlinks inside `basePath`, those could point
36
+ * outside the base. Use `realpath` before calling in symlink-sensitive environments.
37
+ */
38
+ export const securePath = (
39
+ basePath: string,
40
+ userPath: string
41
+ ): Result<string, PermissionError> => {
42
+ const base = resolve(basePath);
43
+ const resolved = resolve(base, userPath);
44
+
45
+ if (!isWithin(base, resolved)) {
46
+ return Result.err(
47
+ new PermissionError(
48
+ `Path traversal detected: "${userPath}" escapes "${basePath}"`,
49
+ {
50
+ context: { basePath: base, resolved, userPath },
51
+ }
52
+ )
53
+ );
54
+ }
55
+
56
+ return Result.ok(resolved);
57
+ };
58
+
59
+ /**
60
+ * Returns `true` if `userPath` (resolved against `basePath`) stays within
61
+ * `basePath`.
62
+ */
63
+ export const isPathSafe = (basePath: string, userPath: string): boolean => {
64
+ const base = resolve(basePath);
65
+ const resolved = resolve(base, userPath);
66
+ return isWithin(base, resolved);
67
+ };
68
+
69
+ /**
70
+ * Joins multiple path segments, resolves them against `basePath`, and
71
+ * validates the result stays within the base directory.
72
+ */
73
+ export const resolveSafePath = (
74
+ basePath: string,
75
+ ...segments: string[]
76
+ ): Result<string, PermissionError> => {
77
+ const base = resolve(basePath);
78
+ const joined = resolve(base, ...segments.map((s) => normalize(s)));
79
+
80
+ if (!isWithin(base, joined)) {
81
+ return Result.err(
82
+ new PermissionError(
83
+ `Path traversal detected: segments escape "${basePath}"`,
84
+ { context: { basePath: base, resolved: joined, segments } }
85
+ )
86
+ );
87
+ }
88
+
89
+ return Result.ok(joined);
90
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Bulk operation schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Bulk operation output wrapper for a given item schema. */
8
+ export const bulkOutput = <T>(itemSchema: z.ZodType<T>) =>
9
+ z.object({
10
+ errors: z
11
+ .array(z.object({ index: z.number(), message: z.string() }))
12
+ .optional(),
13
+ failed: z.number(),
14
+ items: z.array(itemSchema),
15
+ succeeded: z.number(),
16
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Change-tracking schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Before/after change output for a given schema. */
8
+ export const changeOutput = <T>(schema: z.ZodType<T>) =>
9
+ z.object({
10
+ after: schema,
11
+ before: schema.optional(),
12
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Date-range schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Optional since/until date-range fields. */
8
+ export const dateRangeFields = () =>
9
+ z.object({
10
+ since: z.string().optional(),
11
+ until: z.string().optional(),
12
+ });
@@ -0,0 +1,8 @@
1
+ export { paginationFields, paginatedOutput } from './pagination.js';
2
+ export { bulkOutput } from './bulk.js';
3
+ export { timestampFields } from './timestamps.js';
4
+ export { dateRangeFields } from './date-range.js';
5
+ export { sortFields } from './sorting.js';
6
+ export { statusFields } from './status.js';
7
+ export { changeOutput } from './change.js';
8
+ export { progressFields } from './progress.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pagination schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Common pagination input fields. */
8
+ export const paginationFields = () =>
9
+ z.object({
10
+ cursor: z.string().optional(),
11
+ limit: z.number().optional().default(20),
12
+ offset: z.number().optional().default(0),
13
+ });
14
+
15
+ /** Paginated output wrapper for a given item schema. */
16
+ export const paginatedOutput = <T>(itemSchema: z.ZodType<T>) =>
17
+ z.object({
18
+ hasMore: z.boolean(),
19
+ items: z.array(itemSchema),
20
+ nextCursor: z.string().optional(),
21
+ total: z.number(),
22
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Progress schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Progress tracking fields. */
8
+ export const progressFields = () =>
9
+ z.object({
10
+ current: z.number(),
11
+ percentage: z.number().optional(),
12
+ total: z.number(),
13
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sorting schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Sort fields constrained to a set of allowed column names. */
8
+ export const sortFields = <const T extends string>(
9
+ allowedFields: [T, ...T[]]
10
+ ) =>
11
+ z.object({
12
+ sortBy: z.enum(allowedFields).optional(),
13
+ sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
14
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Status schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Standard workflow status field. */
8
+ export const statusFields = () =>
9
+ z.object({
10
+ status: z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']),
11
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Timestamp schema helpers for @ontrails/core/patterns
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ /** Standard createdAt / updatedAt fields. */
8
+ export const timestampFields = () =>
9
+ z.object({
10
+ createdAt: z.string(),
11
+ updatedAt: z.string(),
12
+ });
@@ -0,0 +1,3 @@
1
+ export { DEFAULT_PATTERNS, DEFAULT_SENSITIVE_KEYS } from './patterns.js';
2
+ export { createRedactor } from './redactor.js';
3
+ export type { Redactor, RedactorConfig } from './redactor.js';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Default redaction patterns and sensitive key lists.
3
+ *
4
+ * These are used by {@link createRedactor} to strip secrets from strings
5
+ * and object values before they reach logs, traces, or error payloads.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Regex patterns that match sensitive values inside arbitrary strings
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export const DEFAULT_PATTERNS: RegExp[] = [
13
+ // Credit card numbers: 4 groups of 4 digits separated by spaces or dashes
14
+ /\b\d{4}[- ]\d{4}[- ]\d{4}[- ]\d{4}\b/g,
15
+
16
+ // SSN: XXX-XX-XXXX
17
+ /\b\d{3}-\d{2}-\d{4}\b/g,
18
+
19
+ // Bearer tokens
20
+ /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
21
+
22
+ // Basic auth
23
+ /Basic\s+[A-Za-z0-9+/]+=*/g,
24
+
25
+ // API keys: sk-*, pk_*, sk_* prefixed tokens
26
+ /\b(?:sk-|pk_|sk_)[A-Za-z0-9_-]{8,}\b/g,
27
+
28
+ // JWT tokens: eyJ... (three base64url segments separated by dots)
29
+ /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
30
+ ];
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Object keys whose values should always be redacted
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export const DEFAULT_SENSITIVE_KEYS: string[] = [
37
+ 'password',
38
+ 'secret',
39
+ 'token',
40
+ 'apiKey',
41
+ 'api_key',
42
+ 'authorization',
43
+ 'cookie',
44
+ 'ssn',
45
+ 'creditCard',
46
+ 'credit_card',
47
+ ];
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Redactor — strips sensitive data from strings and objects.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * const r = createRedactor();
7
+ * r.redact("token: Bearer abc123"); // "token: [REDACTED]"
8
+ * r.redactObject({ password: "s3cret" }); // { password: "[REDACTED]" }
9
+ * ```
10
+ */
11
+
12
+ import { DEFAULT_PATTERNS, DEFAULT_SENSITIVE_KEYS } from './patterns.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface RedactorConfig {
19
+ readonly patterns?: RegExp[];
20
+ readonly sensitiveKeys?: string[];
21
+ readonly replacement?: string;
22
+ }
23
+
24
+ export interface Redactor {
25
+ /** Replace all pattern matches in a string with the replacement. */
26
+ redact(value: string): string;
27
+
28
+ /**
29
+ * Deep-clone `obj` and redact:
30
+ * 1. Values whose key matches a sensitive key (case-insensitive).
31
+ * 2. All remaining string values that match a pattern.
32
+ */
33
+ redactObject<T extends Record<string, unknown>>(obj: T): T;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const resetPatterns = (patterns: RegExp[]): void => {
41
+ for (const p of patterns) {
42
+ p.lastIndex = 0;
43
+ }
44
+ };
45
+
46
+ const applyPatterns = (
47
+ value: string,
48
+ patterns: RegExp[],
49
+ replacement: string
50
+ ): string => {
51
+ let result = value;
52
+ for (const pattern of patterns) {
53
+ // Reset in case the regex is stateful (global flag)
54
+ pattern.lastIndex = 0;
55
+ result = result.replace(pattern, replacement);
56
+ }
57
+ return result;
58
+ };
59
+
60
+ const isSensitiveKey = (
61
+ key: string | undefined,
62
+ sensitiveKeysLower: ReadonlySet<string>
63
+ ): boolean => key !== undefined && sensitiveKeysLower.has(key.toLowerCase());
64
+
65
+ type DeepRedact = (
66
+ value: unknown,
67
+ sensitiveKeysLower: ReadonlySet<string>,
68
+ patterns: RegExp[],
69
+ replacement: string,
70
+ currentKey?: string,
71
+ seen?: WeakSet<object>
72
+ ) => unknown;
73
+
74
+ const mapObjectValues = (
75
+ value: object,
76
+ visit: (item: unknown, key: string) => unknown
77
+ ): Record<string, unknown> => {
78
+ const result: Record<string, unknown> = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ result[key] = visit(item, key);
81
+ }
82
+ return result;
83
+ };
84
+
85
+ /** Track and guard against circular references in object graphs. */
86
+ const trackOrCircular = (
87
+ value: object,
88
+ seen: WeakSet<object>
89
+ ): '[Circular]' | null => {
90
+ if (seen.has(value)) {
91
+ return '[Circular]';
92
+ }
93
+ seen.add(value);
94
+ return null;
95
+ };
96
+
97
+ /* oxlint-disable no-use-before-define -- mutual recursion between redactCompound and deepRedact */
98
+
99
+ /** Redact a compound value (array or object), delegating scalars back to deepRedact. */
100
+ const redactCompound = (
101
+ value: unknown[] | object,
102
+ sensitiveKeysLower: ReadonlySet<string>,
103
+ patterns: RegExp[],
104
+ replacement: string,
105
+ seen: WeakSet<object>
106
+ ): unknown => {
107
+ const circular = trackOrCircular(value, seen);
108
+ if (circular) {
109
+ return circular;
110
+ }
111
+ if (Array.isArray(value)) {
112
+ return value.map((item) =>
113
+ deepRedact(
114
+ item,
115
+ sensitiveKeysLower,
116
+ patterns,
117
+ replacement,
118
+ undefined,
119
+ seen
120
+ )
121
+ );
122
+ }
123
+ return mapObjectValues(value, (item, key) =>
124
+ deepRedact(item, sensitiveKeysLower, patterns, replacement, key, seen)
125
+ );
126
+ };
127
+
128
+ const deepRedact: DeepRedact = (
129
+ value,
130
+ sensitiveKeysLower,
131
+ patterns,
132
+ replacement,
133
+ currentKey?,
134
+ seen = new WeakSet<object>()
135
+ ) => {
136
+ if (isSensitiveKey(currentKey, sensitiveKeysLower)) {
137
+ return replacement;
138
+ }
139
+ if (typeof value === 'string') {
140
+ return applyPatterns(value, patterns, replacement);
141
+ }
142
+ if (value !== null && typeof value === 'object') {
143
+ return redactCompound(
144
+ value,
145
+ sensitiveKeysLower,
146
+ patterns,
147
+ replacement,
148
+ seen
149
+ );
150
+ }
151
+ return value;
152
+ };
153
+
154
+ /* oxlint-enable no-use-before-define */
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Factory
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export const createRedactor = (config?: RedactorConfig): Redactor => {
161
+ const patterns = config?.patterns ?? DEFAULT_PATTERNS;
162
+ const sensitiveKeys = config?.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
163
+ const replacement = config?.replacement ?? '[REDACTED]';
164
+
165
+ const sensitiveKeysLower = new Set(sensitiveKeys.map((k) => k.toLowerCase()));
166
+
167
+ return {
168
+ redact(value: string): string {
169
+ resetPatterns(patterns);
170
+ return applyPatterns(value, patterns, replacement);
171
+ },
172
+
173
+ redactObject<T extends Record<string, unknown>>(obj: T): T {
174
+ resetPatterns(patterns);
175
+ return deepRedact(obj, sensitiveKeysLower, patterns, replacement) as T;
176
+ },
177
+ };
178
+ };