@vellumai/credential-executor 0.4.55

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 (42) hide show
  1. package/Dockerfile +55 -0
  2. package/bun.lock +37 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/command-executor.test.ts +1333 -0
  5. package/src/__tests__/command-validator.test.ts +708 -0
  6. package/src/__tests__/command-workspace.test.ts +997 -0
  7. package/src/__tests__/grant-store.test.ts +467 -0
  8. package/src/__tests__/http-executor.test.ts +1251 -0
  9. package/src/__tests__/http-policy.test.ts +970 -0
  10. package/src/__tests__/local-materializers.test.ts +826 -0
  11. package/src/__tests__/managed-materializers.test.ts +961 -0
  12. package/src/__tests__/toolstore.test.ts +539 -0
  13. package/src/__tests__/transport.test.ts +388 -0
  14. package/src/audit/store.ts +188 -0
  15. package/src/commands/auth-adapters.ts +169 -0
  16. package/src/commands/executor.ts +840 -0
  17. package/src/commands/output-scan.ts +157 -0
  18. package/src/commands/profiles.ts +282 -0
  19. package/src/commands/validator.ts +438 -0
  20. package/src/commands/workspace.ts +512 -0
  21. package/src/grants/index.ts +17 -0
  22. package/src/grants/persistent-store.ts +247 -0
  23. package/src/grants/rpc-handlers.ts +269 -0
  24. package/src/grants/temporary-store.ts +219 -0
  25. package/src/http/audit.ts +84 -0
  26. package/src/http/executor.ts +540 -0
  27. package/src/http/path-template.ts +179 -0
  28. package/src/http/policy.ts +256 -0
  29. package/src/http/response-filter.ts +233 -0
  30. package/src/index.ts +106 -0
  31. package/src/main.ts +263 -0
  32. package/src/managed-main.ts +420 -0
  33. package/src/materializers/local.ts +300 -0
  34. package/src/materializers/managed-platform.ts +270 -0
  35. package/src/paths.ts +137 -0
  36. package/src/server.ts +636 -0
  37. package/src/subjects/local.ts +177 -0
  38. package/src/subjects/managed.ts +290 -0
  39. package/src/toolstore/integrity.ts +94 -0
  40. package/src/toolstore/manifest.ts +154 -0
  41. package/src/toolstore/publish.ts +342 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * CES transport tests.
3
+ *
4
+ * Verifies:
5
+ * 1. The managed entrypoint never opens a general localhost command API —
6
+ * the only command transport is via the accepted Unix socket stream.
7
+ * 2. Health probes are served on a dedicated HTTP port, separate from
8
+ * the command transport.
9
+ * 3. Local stdio transports are not accidentally inherited by shell
10
+ * subprocesses (the entrypoint does not open TCP listeners).
11
+ * 4. The CES RPC server correctly performs handshake and dispatches methods.
12
+ * 5. CES private data paths are correctly resolved for both modes.
13
+ */
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+ import { readFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { PassThrough } from "node:stream";
19
+
20
+ import {
21
+ CES_PROTOCOL_VERSION,
22
+ type HandshakeAck,
23
+ type RpcEnvelope,
24
+ } from "@vellumai/ces-contracts";
25
+
26
+ import { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
27
+ import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Create a CesRpcServer wired to in-memory PassThrough streams for testing.
35
+ */
36
+ function createTestServer(handlers: RpcHandlerRegistry = {}) {
37
+ const input = new PassThrough();
38
+ const output = new PassThrough();
39
+ const logs: string[] = [];
40
+
41
+ const server = new CesRpcServer({
42
+ input,
43
+ output,
44
+ handlers,
45
+ logger: {
46
+ log: (msg: string) => logs.push(`LOG: ${msg}`),
47
+ warn: (msg: string) => logs.push(`WARN: ${msg}`),
48
+ error: (msg: string) => logs.push(`ERROR: ${msg}`),
49
+ },
50
+ });
51
+
52
+ /**
53
+ * Collect all output lines from the server.
54
+ */
55
+ function collectOutputLines(): string[] {
56
+ const data = output.read();
57
+ if (!data) return [];
58
+ const text = typeof data === "string" ? data : data.toString("utf-8");
59
+ return text.split("\n").filter((l: string) => l.trim().length > 0);
60
+ }
61
+
62
+ /**
63
+ * Send a JSON message to the server (newline-delimited).
64
+ */
65
+ function send(msg: unknown): void {
66
+ input.write(JSON.stringify(msg) + "\n");
67
+ }
68
+
69
+ /**
70
+ * Send a handshake request and read the response.
71
+ */
72
+ async function handshake(sessionId = "test-session"): Promise<HandshakeAck> {
73
+ send({
74
+ type: "handshake_request",
75
+ protocolVersion: CES_PROTOCOL_VERSION,
76
+ sessionId,
77
+ });
78
+ // Give the server a tick to process
79
+ await new Promise((r) => setTimeout(r, 10));
80
+ const lines = collectOutputLines();
81
+ expect(lines.length).toBeGreaterThanOrEqual(1);
82
+ return JSON.parse(lines[0]) as HandshakeAck;
83
+ }
84
+
85
+ /**
86
+ * Send an RPC request and read the response.
87
+ */
88
+ async function rpc(
89
+ method: string,
90
+ payload: unknown,
91
+ id = "1",
92
+ ): Promise<RpcEnvelope> {
93
+ send({
94
+ type: "rpc",
95
+ id,
96
+ kind: "request",
97
+ method,
98
+ payload,
99
+ timestamp: new Date().toISOString(),
100
+ });
101
+ await new Promise((r) => setTimeout(r, 10));
102
+ const lines = collectOutputLines();
103
+ expect(lines.length).toBeGreaterThanOrEqual(1);
104
+ return JSON.parse(lines[lines.length - 1]) as RpcEnvelope;
105
+ }
106
+
107
+ return { server, input, output, send, collectOutputLines, handshake, rpc, logs };
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // 1. Managed entrypoint never opens a generic localhost command API
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe("managed entrypoint transport isolation", () => {
115
+ test("managed-main.ts does not call Bun.serve for RPC (only health)", () => {
116
+ const src = readFileSync(
117
+ resolve(__dirname, "..", "managed-main.ts"),
118
+ "utf-8",
119
+ );
120
+ // Bun.serve calls in managed-main should only be in startHealthServer
121
+ const bunServeMatches = src.match(/Bun\.serve\(/g);
122
+ expect(bunServeMatches).not.toBeNull();
123
+ // There should be exactly 1 Bun.serve call — for health probes only
124
+ expect(bunServeMatches!.length).toBe(1);
125
+ });
126
+
127
+ test("managed-main.ts uses createNetServer for Unix socket, not Bun.serve", () => {
128
+ const src = readFileSync(
129
+ resolve(__dirname, "..", "managed-main.ts"),
130
+ "utf-8",
131
+ );
132
+ // Uses node:net createServer for the bootstrap socket
133
+ expect(src).toMatch(/createNetServer/);
134
+ // The net server never listens on a TCP port (only a socket path)
135
+ expect(src).not.toMatch(/netServer\.listen\(\d+/);
136
+ });
137
+
138
+ test("managed-main.ts unlinks socket after accepting one connection", () => {
139
+ const src = readFileSync(
140
+ resolve(__dirname, "..", "managed-main.ts"),
141
+ "utf-8",
142
+ );
143
+ // Should unlink the socket path after connection
144
+ expect(src).toMatch(/unlinkSync\(socketPath\)/);
145
+ // Should close the net server after one connection
146
+ expect(src).toMatch(/netServer\.close\(\)/);
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // 2. Health probes on dedicated port
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("health probes", () => {
155
+ test("managed-main.ts serves /healthz and /readyz on a separate port", () => {
156
+ const src = readFileSync(
157
+ resolve(__dirname, "..", "managed-main.ts"),
158
+ "utf-8",
159
+ );
160
+ expect(src).toMatch(/\/healthz/);
161
+ expect(src).toMatch(/\/readyz/);
162
+ // Health server uses Bun.serve on a dedicated port, not the socket
163
+ expect(src).toMatch(/startHealthServer\(healthPort/);
164
+ });
165
+
166
+ test("getHealthPort defaults to 7841", () => {
167
+ // Save and clear env
168
+ const saved = process.env["CES_HEALTH_PORT"];
169
+ delete process.env["CES_HEALTH_PORT"];
170
+ try {
171
+ expect(getHealthPort()).toBe(7841);
172
+ } finally {
173
+ if (saved !== undefined) process.env["CES_HEALTH_PORT"] = saved;
174
+ }
175
+ });
176
+
177
+ test("getHealthPort respects CES_HEALTH_PORT env var", () => {
178
+ const saved = process.env["CES_HEALTH_PORT"];
179
+ process.env["CES_HEALTH_PORT"] = "9999";
180
+ try {
181
+ expect(getHealthPort()).toBe(9999);
182
+ } finally {
183
+ if (saved !== undefined) {
184
+ process.env["CES_HEALTH_PORT"] = saved;
185
+ } else {
186
+ delete process.env["CES_HEALTH_PORT"];
187
+ }
188
+ }
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // 3. Local stdio transport not inherited by subprocesses
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("local entrypoint transport isolation", () => {
197
+ test("main.ts uses process.stdin/stdout, not TCP listeners", () => {
198
+ const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
199
+ // Uses stdin/stdout for transport
200
+ expect(src).toMatch(/process\.stdin/);
201
+ expect(src).toMatch(/process\.stdout/);
202
+ // Does not open any TCP or Unix socket listener
203
+ expect(src).not.toMatch(/Bun\.serve\(/);
204
+ expect(src).not.toMatch(/createServer\(/);
205
+ expect(src).not.toMatch(/\.listen\(/);
206
+ });
207
+
208
+ test("main.ts logs to stderr, not stdout (avoids polluting transport)", () => {
209
+ const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
210
+ // All log output goes to stderr
211
+ expect(src).toMatch(/process\.stderr\.write/);
212
+ // Should not use console.log (which writes to stdout)
213
+ expect(src).not.toMatch(/console\.log\(/);
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // 4. CES RPC server handshake and dispatch
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("CesRpcServer", () => {
222
+ test("completes handshake with correct protocol version", async () => {
223
+ const { server, handshake, input } = createTestServer();
224
+ const servePromise = server.serve();
225
+
226
+ const ack = await handshake();
227
+ expect(ack.type).toBe("handshake_ack");
228
+ expect(ack.accepted).toBe(true);
229
+ expect(ack.protocolVersion).toBe(CES_PROTOCOL_VERSION);
230
+ expect(ack.sessionId).toBe("test-session");
231
+ expect(server.isHandshakeComplete).toBe(true);
232
+ expect(server.currentSessionId).toBe("test-session");
233
+
234
+ server.close();
235
+ input.end();
236
+ });
237
+
238
+ test("rejects handshake with wrong protocol version", async () => {
239
+ const { server, send, collectOutputLines, input } = createTestServer();
240
+ const servePromise = server.serve();
241
+
242
+ send({
243
+ type: "handshake_request",
244
+ protocolVersion: "99.99.99",
245
+ sessionId: "bad-session",
246
+ });
247
+
248
+ await new Promise((r) => setTimeout(r, 10));
249
+ const lines = collectOutputLines();
250
+ expect(lines.length).toBeGreaterThanOrEqual(1);
251
+ const ack = JSON.parse(lines[0]) as HandshakeAck;
252
+ expect(ack.accepted).toBe(false);
253
+ expect(ack.reason).toMatch(/Unsupported protocol version/);
254
+ expect(server.isHandshakeComplete).toBe(false);
255
+
256
+ server.close();
257
+ input.end();
258
+ });
259
+
260
+ test("rejects RPC before handshake", async () => {
261
+ const { server, send, collectOutputLines, input } = createTestServer();
262
+ const servePromise = server.serve();
263
+
264
+ send({
265
+ type: "rpc",
266
+ id: "1",
267
+ kind: "request",
268
+ method: "list_grants",
269
+ payload: {},
270
+ timestamp: new Date().toISOString(),
271
+ });
272
+
273
+ await new Promise((r) => setTimeout(r, 10));
274
+ const lines = collectOutputLines();
275
+ expect(lines.length).toBeGreaterThanOrEqual(1);
276
+ const resp = JSON.parse(lines[0]);
277
+ expect(resp.payload.success).toBe(false);
278
+ expect(resp.payload.error.code).toBe("HANDSHAKE_REQUIRED");
279
+
280
+ server.close();
281
+ input.end();
282
+ });
283
+
284
+ test("dispatches RPC to registered handler", async () => {
285
+ const handlers: RpcHandlerRegistry = {
286
+ list_grants: async (req: unknown) => {
287
+ return { grants: [] };
288
+ },
289
+ };
290
+
291
+ const { server, handshake, rpc, input } = createTestServer(handlers);
292
+ const servePromise = server.serve();
293
+
294
+ await handshake();
295
+
296
+ const resp = await rpc("list_grants", {});
297
+ expect(resp.kind).toBe("response");
298
+ expect(resp.method).toBe("list_grants");
299
+ expect((resp.payload as { grants: unknown[] }).grants).toEqual([]);
300
+
301
+ server.close();
302
+ input.end();
303
+ });
304
+
305
+ test("returns METHOD_NOT_FOUND for unknown methods", async () => {
306
+ const { server, handshake, rpc, input } = createTestServer();
307
+ const servePromise = server.serve();
308
+
309
+ await handshake();
310
+
311
+ const resp = await rpc("nonexistent_method", {});
312
+ expect(resp.kind).toBe("response");
313
+ expect((resp.payload as { success: boolean; error: { code: string } }).error.code).toBe(
314
+ "METHOD_NOT_FOUND",
315
+ );
316
+
317
+ server.close();
318
+ input.end();
319
+ });
320
+
321
+ test("returns HANDLER_ERROR when handler throws", async () => {
322
+ const handlers: RpcHandlerRegistry = {
323
+ fail_method: async () => {
324
+ throw new Error("Intentional test failure");
325
+ },
326
+ };
327
+
328
+ const { server, handshake, rpc, input } = createTestServer(handlers);
329
+ const servePromise = server.serve();
330
+
331
+ await handshake();
332
+
333
+ const resp = await rpc("fail_method", {});
334
+ expect(resp.kind).toBe("response");
335
+ const payload = resp.payload as { success: boolean; error: { code: string; message: string } };
336
+ expect(payload.error.code).toBe("HANDLER_ERROR");
337
+ expect(payload.error.message).toMatch(/Intentional test failure/);
338
+
339
+ server.close();
340
+ input.end();
341
+ });
342
+ });
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // 5. CES private data paths
346
+ // ---------------------------------------------------------------------------
347
+
348
+ describe("CES data paths", () => {
349
+ test("local mode data root includes 'protected/credential-executor'", () => {
350
+ const root = getCesDataRoot("local");
351
+ expect(root).toMatch(/protected[/\\]credential-executor$/);
352
+ });
353
+
354
+ test("managed mode data root is /ces-data", () => {
355
+ expect(getCesDataRoot("managed")).toBe("/ces-data");
356
+ });
357
+
358
+ test("local data root is under the Vellum root, not the workspace", () => {
359
+ const root = getCesDataRoot("local");
360
+ // Must be under .vellum/protected/, NOT .vellum/workspace/
361
+ expect(root).toMatch(/\.vellum[/\\]protected[/\\]/);
362
+ expect(root).not.toMatch(/workspace/);
363
+ });
364
+
365
+ test("getBootstrapSocketPath defaults to /run/ces/ces.sock", () => {
366
+ const saved = process.env["CES_BOOTSTRAP_SOCKET"];
367
+ delete process.env["CES_BOOTSTRAP_SOCKET"];
368
+ try {
369
+ expect(getBootstrapSocketPath()).toBe("/run/ces/ces.sock");
370
+ } finally {
371
+ if (saved !== undefined) process.env["CES_BOOTSTRAP_SOCKET"] = saved;
372
+ }
373
+ });
374
+
375
+ test("getBootstrapSocketPath respects CES_BOOTSTRAP_SOCKET env var", () => {
376
+ const saved = process.env["CES_BOOTSTRAP_SOCKET"];
377
+ process.env["CES_BOOTSTRAP_SOCKET"] = "/tmp/test-ces.sock";
378
+ try {
379
+ expect(getBootstrapSocketPath()).toBe("/tmp/test-ces.sock");
380
+ } finally {
381
+ if (saved !== undefined) {
382
+ process.env["CES_BOOTSTRAP_SOCKET"] = saved;
383
+ } else {
384
+ delete process.env["CES_BOOTSTRAP_SOCKET"];
385
+ }
386
+ }
387
+ });
388
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * CES audit record persistence.
3
+ *
4
+ * Persists token-free audit record summaries to `audit.jsonl` inside
5
+ * the CES-private data root. Each line is a self-contained JSON object
6
+ * conforming to the `AuditRecordSummary` schema from `@vellumai/ces-contracts`.
7
+ *
8
+ * Design principles:
9
+ * - **Append-only**: Records are appended one per line. The file is never
10
+ * rewritten or truncated during normal operation.
11
+ * - **Token-free**: Audit records must never contain raw secrets, auth
12
+ * tokens, raw headers, or raw response bodies. Only sanitized summaries
13
+ * (method, URL template, status code, credential handle, grant ID) are
14
+ * persisted.
15
+ * - **Fail-open for reads**: If the file is corrupt or missing, reads
16
+ * return an empty array rather than throwing. Writes still throw on I/O
17
+ * failure so callers know when persistence is broken.
18
+ * - **Bounded reads**: The `list` method supports limit and cursor-based
19
+ * pagination to avoid reading the entire log into memory.
20
+ */
21
+
22
+ import {
23
+ appendFileSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+
30
+ import type { AuditRecordSummary } from "@vellumai/ces-contracts";
31
+
32
+ import { getCesAuditDir } from "../paths.js";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const AUDIT_FILENAME = "audit.jsonl";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Store implementation
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export class AuditStore {
45
+ private readonly filePath: string;
46
+
47
+ constructor(auditDir?: string) {
48
+ const dir = auditDir ?? getCesAuditDir();
49
+ this.filePath = join(dir, AUDIT_FILENAME);
50
+ }
51
+
52
+ // -----------------------------------------------------------------------
53
+ // Public API
54
+ // -----------------------------------------------------------------------
55
+
56
+ /**
57
+ * Ensure the parent directory exists. Safe to call multiple times.
58
+ */
59
+ init(): void {
60
+ const dir = dirname(this.filePath);
61
+ if (!existsSync(dir)) {
62
+ mkdirSync(dir, { recursive: true });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Append a token-free audit record summary to the log.
68
+ *
69
+ * Throws on I/O failure (callers should handle gracefully — audit
70
+ * persistence failure must not block the execution pipeline).
71
+ */
72
+ append(record: AuditRecordSummary): void {
73
+ const dir = dirname(this.filePath);
74
+ if (!existsSync(dir)) {
75
+ mkdirSync(dir, { recursive: true });
76
+ }
77
+
78
+ const line = JSON.stringify(record) + "\n";
79
+ appendFileSync(this.filePath, line, { mode: 0o600 });
80
+ }
81
+
82
+ /**
83
+ * List audit records with optional filtering and pagination.
84
+ *
85
+ * Records are returned in reverse-chronological order (newest first).
86
+ *
87
+ * @param options.sessionId - Filter by session ID.
88
+ * @param options.credentialHandle - Filter by credential handle.
89
+ * @param options.grantId - Filter by grant ID.
90
+ * @param options.limit - Maximum number of records to return (default: 50).
91
+ * @param options.cursor - Opaque cursor from a previous response to
92
+ * continue pagination. The cursor is the 0-based line offset encoded
93
+ * as a string.
94
+ *
95
+ * @returns An object with `records` and `nextCursor`. `nextCursor` is
96
+ * null when there are no more results.
97
+ */
98
+ list(options?: {
99
+ sessionId?: string;
100
+ credentialHandle?: string;
101
+ grantId?: string;
102
+ limit?: number;
103
+ cursor?: string;
104
+ }): { records: AuditRecordSummary[]; nextCursor: string | null } {
105
+ const limit = options?.limit ?? 50;
106
+
107
+ const allRecords = this.readAll();
108
+
109
+ // Reverse for newest-first ordering
110
+ allRecords.reverse();
111
+
112
+ // Apply filters
113
+ let filtered = allRecords;
114
+ if (options?.sessionId) {
115
+ filtered = filtered.filter((r) => r.sessionId === options.sessionId);
116
+ }
117
+ if (options?.credentialHandle) {
118
+ filtered = filtered.filter(
119
+ (r) => r.credentialHandle === options.credentialHandle,
120
+ );
121
+ }
122
+ if (options?.grantId) {
123
+ filtered = filtered.filter((r) => r.grantId === options.grantId);
124
+ }
125
+
126
+ // Apply cursor-based pagination
127
+ const offset = options?.cursor ? parseInt(options.cursor, 10) : 0;
128
+ const startIdx = isNaN(offset) ? 0 : offset;
129
+
130
+ const page = filtered.slice(startIdx, startIdx + limit);
131
+ const hasMore = startIdx + limit < filtered.length;
132
+ const nextCursor = hasMore ? String(startIdx + limit) : null;
133
+
134
+ return { records: page, nextCursor };
135
+ }
136
+
137
+ /**
138
+ * Return the total number of records in the log.
139
+ *
140
+ * Returns 0 if the file does not exist or is unreadable.
141
+ */
142
+ count(): number {
143
+ return this.readAll().length;
144
+ }
145
+
146
+ // -----------------------------------------------------------------------
147
+ // Internals
148
+ // -----------------------------------------------------------------------
149
+
150
+ /**
151
+ * Read all records from the JSONL file, skipping malformed lines.
152
+ *
153
+ * Returns an empty array if the file is missing or unreadable.
154
+ */
155
+ private readAll(): AuditRecordSummary[] {
156
+ if (!existsSync(this.filePath)) return [];
157
+
158
+ let raw: string;
159
+ try {
160
+ raw = readFileSync(this.filePath, "utf-8");
161
+ } catch {
162
+ return [];
163
+ }
164
+
165
+ const records: AuditRecordSummary[] = [];
166
+ const lines = raw.split("\n");
167
+
168
+ for (const line of lines) {
169
+ const trimmed = line.trim();
170
+ if (trimmed.length === 0) continue;
171
+
172
+ try {
173
+ const parsed = JSON.parse(trimmed) as AuditRecordSummary;
174
+ // Minimal validation — must have auditId and timestamp
175
+ if (
176
+ typeof parsed.auditId === "string" &&
177
+ typeof parsed.timestamp === "string"
178
+ ) {
179
+ records.push(parsed);
180
+ }
181
+ } catch {
182
+ // Skip malformed lines
183
+ }
184
+ }
185
+
186
+ return records;
187
+ }
188
+ }