supascan 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -0
- package/commands/analyze.command.ts +187 -0
- package/commands/dump.command.ts +98 -0
- package/commands/rpc.command.ts +205 -0
- package/context.ts +84 -0
- package/index.ts +94 -0
- package/package.json +43 -0
- package/services/analyzer.service.test.ts +193 -0
- package/services/analyzer.service.ts +190 -0
- package/services/extractor.service.test.ts +194 -0
- package/services/extractor.service.ts +230 -0
- package/services/html-renderer.service.tsx +1246 -0
- package/services/supabase.service.test.ts +352 -0
- package/services/supabase.service.ts +316 -0
- package/utils.ts +127 -0
- package/version.ts +3 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { createMockCLIContext } from "../mocks";
|
|
3
|
+
import { SupabaseService } from "./supabase.service";
|
|
4
|
+
|
|
5
|
+
describe("SupabaseService", () => {
|
|
6
|
+
let ctx: ReturnType<typeof createMockCLIContext>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
ctx = createMockCLIContext();
|
|
10
|
+
mock.restore();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("getSchemas", () => {
|
|
14
|
+
test("parses schemas from error message", async () => {
|
|
15
|
+
const mockSelect = mock(() => ({
|
|
16
|
+
data: null,
|
|
17
|
+
error: {
|
|
18
|
+
message:
|
|
19
|
+
'relation "" does not exist. Available schemas following: public, auth, storage',
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const mockFrom = mock(() => ({
|
|
24
|
+
select: mockSelect,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const mockSchema = mock(() => ({
|
|
28
|
+
from: mockFrom,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const mockClient = {
|
|
32
|
+
schema: mockSchema,
|
|
33
|
+
};
|
|
34
|
+
ctx.client = mockClient as any;
|
|
35
|
+
|
|
36
|
+
const result = await SupabaseService.getSchemas(ctx);
|
|
37
|
+
|
|
38
|
+
expect(result.success).toBe(true);
|
|
39
|
+
if (result.success) {
|
|
40
|
+
expect(result.value).toEqual(["public", "auth", "storage"]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("handles empty schema list", async () => {
|
|
45
|
+
const mockClient = {
|
|
46
|
+
schema: mock(() => ({
|
|
47
|
+
from: mock(() => ({
|
|
48
|
+
select: mock(() => ({
|
|
49
|
+
data: null,
|
|
50
|
+
error: {
|
|
51
|
+
message: 'relation "" does not exist. Available schemas:',
|
|
52
|
+
},
|
|
53
|
+
})),
|
|
54
|
+
})),
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
ctx.client = mockClient as any;
|
|
58
|
+
|
|
59
|
+
const result = await SupabaseService.getSchemas(ctx);
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
if (result.success) {
|
|
63
|
+
expect(result.value).toEqual([]);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("getTables", () => {
|
|
69
|
+
test("filters RPC paths from swagger", async () => {
|
|
70
|
+
const mockClient = {
|
|
71
|
+
schema: mock(() => ({
|
|
72
|
+
from: mock(() => ({
|
|
73
|
+
select: mock(() => ({
|
|
74
|
+
data: {
|
|
75
|
+
paths: {
|
|
76
|
+
"/users": {},
|
|
77
|
+
"/posts": {},
|
|
78
|
+
"/rpc/get_user": {},
|
|
79
|
+
"/rpc/create_post": {},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
error: null,
|
|
83
|
+
})),
|
|
84
|
+
})),
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
ctx.client = mockClient as any;
|
|
88
|
+
|
|
89
|
+
const result = await SupabaseService.getTables(ctx, "public");
|
|
90
|
+
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
if (result.success) {
|
|
93
|
+
expect(result.value).toEqual(["users", "posts"]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getRPCs", () => {
|
|
99
|
+
test("filters table paths from swagger", async () => {
|
|
100
|
+
const mockClient = {
|
|
101
|
+
schema: mock(() => ({
|
|
102
|
+
from: mock(() => ({
|
|
103
|
+
select: mock(() => ({
|
|
104
|
+
data: {
|
|
105
|
+
paths: {
|
|
106
|
+
"/users": {},
|
|
107
|
+
"/posts": {},
|
|
108
|
+
"/rpc/get_user": {},
|
|
109
|
+
"/rpc/create_post": {},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
error: null,
|
|
113
|
+
})),
|
|
114
|
+
})),
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
ctx.client = mockClient as any;
|
|
118
|
+
|
|
119
|
+
const result = await SupabaseService.getRPCs(ctx, "public");
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(true);
|
|
122
|
+
if (result.success) {
|
|
123
|
+
expect(result.value).toEqual(["rpc/get_user", "rpc/create_post"]);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("getRPCsWithParameters", () => {
|
|
129
|
+
test("extracts parameters from swagger", async () => {
|
|
130
|
+
const mockClient = {
|
|
131
|
+
schema: mock(() => ({
|
|
132
|
+
from: mock(() => ({
|
|
133
|
+
select: mock(() => ({
|
|
134
|
+
data: {
|
|
135
|
+
paths: {
|
|
136
|
+
"/rpc/get_user": {
|
|
137
|
+
post: {
|
|
138
|
+
parameters: [
|
|
139
|
+
{
|
|
140
|
+
in: "body",
|
|
141
|
+
schema: {
|
|
142
|
+
properties: {
|
|
143
|
+
id: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description: "User ID",
|
|
146
|
+
},
|
|
147
|
+
name: {
|
|
148
|
+
type: "string",
|
|
149
|
+
format: "email",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
required: ["id"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
error: null,
|
|
161
|
+
})),
|
|
162
|
+
})),
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
ctx.client = mockClient as any;
|
|
166
|
+
|
|
167
|
+
const result = await SupabaseService.getRPCsWithParameters(ctx, "public");
|
|
168
|
+
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
if (result.success) {
|
|
171
|
+
expect(result.value).toHaveLength(1);
|
|
172
|
+
expect(result.value[0]?.name).toBe("rpc/get_user");
|
|
173
|
+
expect(result.value[0]?.parameters).toHaveLength(2);
|
|
174
|
+
expect(result.value[0]?.parameters[0]).toEqual({
|
|
175
|
+
name: "id",
|
|
176
|
+
type: "string",
|
|
177
|
+
required: true,
|
|
178
|
+
description: "User ID",
|
|
179
|
+
});
|
|
180
|
+
expect(result.value[0]?.parameters[1]).toEqual({
|
|
181
|
+
name: "name",
|
|
182
|
+
type: "string",
|
|
183
|
+
format: "email",
|
|
184
|
+
required: false,
|
|
185
|
+
description: undefined,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("testTableRead", () => {
|
|
192
|
+
test("returns readable status when data exists", async () => {
|
|
193
|
+
const mockClient = {
|
|
194
|
+
schema: mock(() => ({
|
|
195
|
+
from: mock(() => ({
|
|
196
|
+
select: mock(() => ({
|
|
197
|
+
limit: mock(() => ({
|
|
198
|
+
data: [{ id: 1, name: "test" }],
|
|
199
|
+
error: null,
|
|
200
|
+
})),
|
|
201
|
+
})),
|
|
202
|
+
})),
|
|
203
|
+
})),
|
|
204
|
+
};
|
|
205
|
+
ctx.client = mockClient as any;
|
|
206
|
+
|
|
207
|
+
const result = await SupabaseService.testTableRead(
|
|
208
|
+
ctx,
|
|
209
|
+
"public",
|
|
210
|
+
"users",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(result.success).toBe(true);
|
|
214
|
+
if (result.success) {
|
|
215
|
+
expect(result.value).toEqual({
|
|
216
|
+
status: "readable",
|
|
217
|
+
accessible: true,
|
|
218
|
+
hasData: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("returns denied status when access denied", async () => {
|
|
224
|
+
const mockClient = {
|
|
225
|
+
schema: mock(() => ({
|
|
226
|
+
from: mock(() => ({
|
|
227
|
+
select: mock(() => ({
|
|
228
|
+
limit: mock(() => ({
|
|
229
|
+
data: null,
|
|
230
|
+
error: { message: "permission denied" },
|
|
231
|
+
})),
|
|
232
|
+
})),
|
|
233
|
+
})),
|
|
234
|
+
})),
|
|
235
|
+
};
|
|
236
|
+
ctx.client = mockClient as any;
|
|
237
|
+
|
|
238
|
+
const result = await SupabaseService.testTableRead(
|
|
239
|
+
ctx,
|
|
240
|
+
"public",
|
|
241
|
+
"users",
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(true);
|
|
245
|
+
if (result.success) {
|
|
246
|
+
expect(result.value).toEqual({
|
|
247
|
+
status: "denied",
|
|
248
|
+
accessible: false,
|
|
249
|
+
hasData: false,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("returns empty status when no data", async () => {
|
|
255
|
+
const mockClient = {
|
|
256
|
+
schema: mock(() => ({
|
|
257
|
+
from: mock(() => ({
|
|
258
|
+
select: mock(() => ({
|
|
259
|
+
limit: mock(() => ({
|
|
260
|
+
data: [],
|
|
261
|
+
error: null,
|
|
262
|
+
})),
|
|
263
|
+
})),
|
|
264
|
+
})),
|
|
265
|
+
})),
|
|
266
|
+
};
|
|
267
|
+
ctx.client = mockClient as any;
|
|
268
|
+
|
|
269
|
+
const result = await SupabaseService.testTableRead(
|
|
270
|
+
ctx,
|
|
271
|
+
"public",
|
|
272
|
+
"users",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(result.success).toBe(true);
|
|
276
|
+
if (result.success) {
|
|
277
|
+
expect(result.value).toEqual({
|
|
278
|
+
status: "empty",
|
|
279
|
+
accessible: true,
|
|
280
|
+
hasData: false,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("dumpTable", () => {
|
|
287
|
+
test("returns columns and rows with count", async () => {
|
|
288
|
+
const mockClient = {
|
|
289
|
+
schema: mock(() => ({
|
|
290
|
+
from: mock(() => ({
|
|
291
|
+
select: mock(() => ({
|
|
292
|
+
limit: mock(() => ({
|
|
293
|
+
data: [
|
|
294
|
+
{ id: 1, name: "Alice" },
|
|
295
|
+
{ id: 2, name: "Bob" },
|
|
296
|
+
],
|
|
297
|
+
error: null,
|
|
298
|
+
count: 100,
|
|
299
|
+
})),
|
|
300
|
+
})),
|
|
301
|
+
})),
|
|
302
|
+
})),
|
|
303
|
+
};
|
|
304
|
+
ctx.client = mockClient as any;
|
|
305
|
+
|
|
306
|
+
const result = await SupabaseService.dumpTable(
|
|
307
|
+
ctx,
|
|
308
|
+
"public",
|
|
309
|
+
"users",
|
|
310
|
+
10,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(result.success).toBe(true);
|
|
314
|
+
if (result.success) {
|
|
315
|
+
expect(result.value.columns).toEqual(["id", "name"]);
|
|
316
|
+
expect(result.value.rows).toHaveLength(2);
|
|
317
|
+
expect(result.value.count).toBe(100);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("handles empty table", async () => {
|
|
322
|
+
const mockClient = {
|
|
323
|
+
schema: mock(() => ({
|
|
324
|
+
from: mock(() => ({
|
|
325
|
+
select: mock(() => ({
|
|
326
|
+
limit: mock(() => ({
|
|
327
|
+
data: [],
|
|
328
|
+
error: null,
|
|
329
|
+
count: 0,
|
|
330
|
+
})),
|
|
331
|
+
})),
|
|
332
|
+
})),
|
|
333
|
+
})),
|
|
334
|
+
};
|
|
335
|
+
ctx.client = mockClient as any;
|
|
336
|
+
|
|
337
|
+
const result = await SupabaseService.dumpTable(
|
|
338
|
+
ctx,
|
|
339
|
+
"public",
|
|
340
|
+
"users",
|
|
341
|
+
10,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(result.success).toBe(true);
|
|
345
|
+
if (result.success) {
|
|
346
|
+
expect(result.value.columns).toEqual([]);
|
|
347
|
+
expect(result.value.rows).toEqual([]);
|
|
348
|
+
expect(result.value.count).toBe(0);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { CLIContext } from "../context";
|
|
2
|
+
import { type Result, err, log, ok } from "../utils";
|
|
3
|
+
|
|
4
|
+
export type SupabaseSwagger = {
|
|
5
|
+
swagger: string;
|
|
6
|
+
paths: Record<string, unknown>;
|
|
7
|
+
info?: {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
};
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TableAccessStatus = "denied" | "readable" | "empty";
|
|
16
|
+
|
|
17
|
+
export type TableAccessResult = {
|
|
18
|
+
status: TableAccessStatus;
|
|
19
|
+
accessible: boolean;
|
|
20
|
+
hasData: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RPCParameter = {
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
format?: string;
|
|
27
|
+
required: boolean;
|
|
28
|
+
description?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RPCFunction = {
|
|
32
|
+
name: string;
|
|
33
|
+
parameters: RPCParameter[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export abstract class SupabaseService {
|
|
37
|
+
public static async getSchemas(
|
|
38
|
+
ctx: CLIContext,
|
|
39
|
+
nonexistantSchema = "NONEXISTANT_SCHEMA_THAT_SHOULDNT_EXIST",
|
|
40
|
+
): Promise<Result<string[]>> {
|
|
41
|
+
log.debug(ctx, "Fetching schemas...");
|
|
42
|
+
|
|
43
|
+
const { data, error } = await ctx.client
|
|
44
|
+
.schema(nonexistantSchema)
|
|
45
|
+
.from("")
|
|
46
|
+
.select();
|
|
47
|
+
|
|
48
|
+
if (data) {
|
|
49
|
+
return err(new Error("Schema exists, this shouldn't happen"));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const schemas =
|
|
53
|
+
error?.message
|
|
54
|
+
.split("following: ")[1]
|
|
55
|
+
?.split(",")
|
|
56
|
+
.map((schema) => schema.trim()) ?? [];
|
|
57
|
+
|
|
58
|
+
log.debug(ctx, `Found ${schemas.length} schemas`);
|
|
59
|
+
return ok(schemas);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static async getSwagger(
|
|
63
|
+
ctx: CLIContext,
|
|
64
|
+
schema: string,
|
|
65
|
+
): Promise<Result<SupabaseSwagger>> {
|
|
66
|
+
log.debug(ctx, `Fetching swagger for schema: ${schema}`);
|
|
67
|
+
|
|
68
|
+
const { data, error } = await ctx.client.schema(schema).from("").select();
|
|
69
|
+
|
|
70
|
+
if (error) {
|
|
71
|
+
return err(error);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return ok(data as unknown as SupabaseSwagger);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public static async getTables(
|
|
78
|
+
ctx: CLIContext,
|
|
79
|
+
schema: string,
|
|
80
|
+
): Promise<Result<string[]>> {
|
|
81
|
+
log.debug(ctx, `Fetching tables for schema: ${schema}`);
|
|
82
|
+
|
|
83
|
+
const swaggerResult = await this.getSwagger(ctx, schema);
|
|
84
|
+
|
|
85
|
+
if (!swaggerResult.success) {
|
|
86
|
+
return err(swaggerResult.error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tables = Object.keys(swaggerResult.value.paths)
|
|
90
|
+
.filter((key) => !key.startsWith("/rpc/"))
|
|
91
|
+
.map((key) => key.slice(1))
|
|
92
|
+
.filter((key) => !!key);
|
|
93
|
+
|
|
94
|
+
log.debug(ctx, `Found ${tables.length} tables`);
|
|
95
|
+
return ok(tables);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public static async getRPCs(
|
|
99
|
+
ctx: CLIContext,
|
|
100
|
+
schema: string,
|
|
101
|
+
): Promise<Result<string[]>> {
|
|
102
|
+
log.debug(ctx, `Fetching RPCs for schema: ${schema}`);
|
|
103
|
+
|
|
104
|
+
const swaggerResult = await this.getSwagger(ctx, schema);
|
|
105
|
+
|
|
106
|
+
if (!swaggerResult.success) {
|
|
107
|
+
return err(swaggerResult.error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const rpcs = Object.keys(swaggerResult.value.paths)
|
|
111
|
+
.filter((key) => key.startsWith("/rpc/"))
|
|
112
|
+
.map((key) => key.slice(1));
|
|
113
|
+
|
|
114
|
+
log.debug(ctx, `Found ${rpcs.length} RPCs`);
|
|
115
|
+
return ok(rpcs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public static async getRPCsWithParameters(
|
|
119
|
+
ctx: CLIContext,
|
|
120
|
+
schema: string,
|
|
121
|
+
): Promise<Result<RPCFunction[]>> {
|
|
122
|
+
log.debug(ctx, `Fetching RPCs with parameters for schema: ${schema}`);
|
|
123
|
+
|
|
124
|
+
const swaggerResult = await this.getSwagger(ctx, schema);
|
|
125
|
+
|
|
126
|
+
if (!swaggerResult.success) {
|
|
127
|
+
return err(swaggerResult.error);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rpcFunctions: RPCFunction[] = [];
|
|
131
|
+
|
|
132
|
+
Object.entries(swaggerResult.value.paths).forEach(
|
|
133
|
+
([path, methods]: [string, any]) => {
|
|
134
|
+
if (path.startsWith("/rpc/")) {
|
|
135
|
+
const rpcName = path.slice(1);
|
|
136
|
+
|
|
137
|
+
const postMethod = methods.post; // TODO: is this right?
|
|
138
|
+
if (postMethod && postMethod.parameters) {
|
|
139
|
+
const parameters: RPCParameter[] = [];
|
|
140
|
+
|
|
141
|
+
postMethod.parameters.forEach((param: any) => {
|
|
142
|
+
if (
|
|
143
|
+
param.in === "body" &&
|
|
144
|
+
param.schema &&
|
|
145
|
+
param.schema.properties
|
|
146
|
+
) {
|
|
147
|
+
const requiredParams = param.schema.required || [];
|
|
148
|
+
|
|
149
|
+
Object.entries(param.schema.properties).forEach(
|
|
150
|
+
([paramName, paramDef]: [string, any]) => {
|
|
151
|
+
parameters.push({
|
|
152
|
+
name: paramName,
|
|
153
|
+
type: paramDef.type || "unknown",
|
|
154
|
+
format: paramDef.format,
|
|
155
|
+
required: requiredParams.includes(paramName),
|
|
156
|
+
description: paramDef.description,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
rpcFunctions.push({
|
|
164
|
+
name: rpcName,
|
|
165
|
+
parameters,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
log.debug(ctx, `Found ${rpcFunctions.length} RPCs with parameters`);
|
|
173
|
+
return ok(rpcFunctions);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public static async callRPC(
|
|
177
|
+
ctx: CLIContext,
|
|
178
|
+
schema: string,
|
|
179
|
+
rpcName: string,
|
|
180
|
+
args: Record<string, any> = {},
|
|
181
|
+
options: {
|
|
182
|
+
get?: boolean;
|
|
183
|
+
explain?: boolean;
|
|
184
|
+
limit?: number;
|
|
185
|
+
} = {
|
|
186
|
+
get: false,
|
|
187
|
+
explain: false,
|
|
188
|
+
},
|
|
189
|
+
): Promise<Result<any>> {
|
|
190
|
+
log.debug(ctx, `Calling RPC: ${schema}.${rpcName}`, args);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
let query = ctx.client.schema(schema).rpc(rpcName, args);
|
|
194
|
+
|
|
195
|
+
if (options.limit && !options.explain) {
|
|
196
|
+
query = query.limit(options.limit);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { data, error } = options.explain
|
|
200
|
+
? await query.explain({
|
|
201
|
+
analyze: true,
|
|
202
|
+
format: "text",
|
|
203
|
+
verbose: true,
|
|
204
|
+
settings: true,
|
|
205
|
+
wal: true,
|
|
206
|
+
buffers: true,
|
|
207
|
+
})
|
|
208
|
+
: await query;
|
|
209
|
+
|
|
210
|
+
if (error) {
|
|
211
|
+
log.debug(ctx, "RPC error:", error);
|
|
212
|
+
return err(new Error(`RPC call failed: ${error.message}`));
|
|
213
|
+
}
|
|
214
|
+
return ok(data);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
log.debug(ctx, "RPC exception:", error);
|
|
217
|
+
return err(
|
|
218
|
+
new Error(
|
|
219
|
+
`RPC call exception: ${error instanceof Error ? error.message : String(error)}`,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public static async testTableRead(
|
|
226
|
+
ctx: CLIContext,
|
|
227
|
+
schema: string,
|
|
228
|
+
table: string,
|
|
229
|
+
): Promise<Result<TableAccessResult>> {
|
|
230
|
+
log.debug(ctx, `Testing read access for ${schema}.${table}`);
|
|
231
|
+
|
|
232
|
+
const { data, error } = await ctx.client
|
|
233
|
+
.schema(schema)
|
|
234
|
+
.from(table)
|
|
235
|
+
.select("*")
|
|
236
|
+
.limit(1);
|
|
237
|
+
|
|
238
|
+
if (error) {
|
|
239
|
+
log.debug(ctx, `Access denied for ${table}: ${error.message}`);
|
|
240
|
+
return ok({ status: "denied", accessible: false, hasData: false });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const hasData = data && data.length > 0;
|
|
244
|
+
|
|
245
|
+
if (hasData) {
|
|
246
|
+
log.debug(ctx, `Table ${table} is readable with data (EXPOSED)`);
|
|
247
|
+
return ok({ status: "readable", accessible: true, hasData: true });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
log.debug(ctx, `Table ${table} returned 0 rows (empty or RLS blocked)`);
|
|
251
|
+
return ok({ status: "empty", accessible: true, hasData: false });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
public static async testTablesRead(
|
|
255
|
+
ctx: CLIContext,
|
|
256
|
+
schema: string,
|
|
257
|
+
tables: string[],
|
|
258
|
+
): Promise<Result<Record<string, TableAccessResult>>> {
|
|
259
|
+
log.debug(ctx, `Testing read access for ${tables.length} tables`);
|
|
260
|
+
|
|
261
|
+
const results = await Promise.all(
|
|
262
|
+
tables.map(async (table) => {
|
|
263
|
+
const result = await this.testTableRead(ctx, schema, table);
|
|
264
|
+
return {
|
|
265
|
+
table,
|
|
266
|
+
access: result.success
|
|
267
|
+
? result.value
|
|
268
|
+
: { status: "denied" as const, accessible: false, hasData: false },
|
|
269
|
+
};
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const accessMap = results.reduce(
|
|
274
|
+
(acc, { table, access }) => {
|
|
275
|
+
acc[table] = access;
|
|
276
|
+
return acc;
|
|
277
|
+
},
|
|
278
|
+
{} as Record<string, TableAccessResult>,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return ok(accessMap);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public static async dumpTable(
|
|
285
|
+
ctx: CLIContext,
|
|
286
|
+
schema: string,
|
|
287
|
+
table: string,
|
|
288
|
+
limit = 10,
|
|
289
|
+
): Promise<
|
|
290
|
+
Result<{
|
|
291
|
+
columns: string[];
|
|
292
|
+
rows: Record<string, unknown>[];
|
|
293
|
+
count: number;
|
|
294
|
+
}>
|
|
295
|
+
> {
|
|
296
|
+
log.debug(ctx, `Dumping table ${schema}.${table}`);
|
|
297
|
+
|
|
298
|
+
const { data, error, count } = await ctx.client
|
|
299
|
+
.schema(schema)
|
|
300
|
+
.from(table)
|
|
301
|
+
.select("*", { count: "exact" })
|
|
302
|
+
.limit(limit);
|
|
303
|
+
|
|
304
|
+
if (error) {
|
|
305
|
+
return err(error);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const columns = data && data.length > 0 ? Object.keys(data[0] ?? {}) : [];
|
|
309
|
+
|
|
310
|
+
return ok({
|
|
311
|
+
columns,
|
|
312
|
+
rows: data ?? [],
|
|
313
|
+
count: count ?? 0,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|