@studious-lms/server 1.27.0 → 1.28.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 (106) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +31 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/instrument.js +9 -4
  5. package/dist/instrument.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts +15 -0
  7. package/dist/lib/fileUpload.d.ts.map +1 -1
  8. package/dist/lib/fileUpload.js +43 -2
  9. package/dist/lib/fileUpload.js.map +1 -1
  10. package/dist/pipelines/aiLabChatContract.d.ts +2 -2
  11. package/dist/pipelines/generateVisualization.d.ts +33 -0
  12. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  13. package/dist/pipelines/generateVisualization.js +79 -0
  14. package/dist/pipelines/generateVisualization.js.map +1 -0
  15. package/dist/routers/_app.d.ts +891 -53
  16. package/dist/routers/_app.d.ts.map +1 -1
  17. package/dist/routers/_app.js +4 -2
  18. package/dist/routers/_app.js.map +1 -1
  19. package/dist/routers/admin.d.ts +297 -0
  20. package/dist/routers/admin.d.ts.map +1 -1
  21. package/dist/routers/admin.js +43 -2
  22. package/dist/routers/admin.js.map +1 -1
  23. package/dist/routers/class.d.ts.map +1 -1
  24. package/dist/routers/class.js +16 -3
  25. package/dist/routers/class.js.map +1 -1
  26. package/dist/routers/module.d.ts +1 -1
  27. package/dist/routers/visualization.d.ts +124 -0
  28. package/dist/routers/visualization.d.ts.map +1 -0
  29. package/dist/routers/visualization.js +41 -0
  30. package/dist/routers/visualization.js.map +1 -0
  31. package/dist/services/adminClass.d.ts +153 -0
  32. package/dist/services/adminClass.d.ts.map +1 -0
  33. package/dist/services/adminClass.js +196 -0
  34. package/dist/services/adminClass.js.map +1 -0
  35. package/dist/services/adminInstitution.d.ts +2 -0
  36. package/dist/services/adminInstitution.d.ts.map +1 -1
  37. package/dist/services/adminInstitution.js +11 -4
  38. package/dist/services/adminInstitution.js.map +1 -1
  39. package/dist/services/adminMetrics.d.ts +8 -0
  40. package/dist/services/adminMetrics.d.ts.map +1 -1
  41. package/dist/services/adminMetrics.js +30 -3
  42. package/dist/services/adminMetrics.js.map +1 -1
  43. package/dist/services/adminSpam.d.ts +76 -0
  44. package/dist/services/adminSpam.d.ts.map +1 -0
  45. package/dist/services/adminSpam.js +163 -0
  46. package/dist/services/adminSpam.js.map +1 -0
  47. package/dist/services/adminSql.d.ts +36 -0
  48. package/dist/services/adminSql.d.ts.map +1 -0
  49. package/dist/services/adminSql.js +172 -0
  50. package/dist/services/adminSql.js.map +1 -0
  51. package/dist/services/adminTelemetry.d.ts +56 -0
  52. package/dist/services/adminTelemetry.d.ts.map +1 -0
  53. package/dist/services/adminTelemetry.js +147 -0
  54. package/dist/services/adminTelemetry.js.map +1 -0
  55. package/dist/services/adminUser.d.ts +45 -0
  56. package/dist/services/adminUser.d.ts.map +1 -1
  57. package/dist/services/adminUser.js +48 -11
  58. package/dist/services/adminUser.js.map +1 -1
  59. package/dist/services/auth.d.ts.map +1 -1
  60. package/dist/services/auth.js +10 -4
  61. package/dist/services/auth.js.map +1 -1
  62. package/dist/services/fees.d.ts.map +1 -1
  63. package/dist/services/fees.js +7 -3
  64. package/dist/services/fees.js.map +1 -1
  65. package/dist/services/module.d.ts.map +1 -1
  66. package/dist/services/module.js +23 -4
  67. package/dist/services/module.js.map +1 -1
  68. package/dist/services/visualization.d.ts +72 -0
  69. package/dist/services/visualization.d.ts.map +1 -0
  70. package/dist/services/visualization.js +122 -0
  71. package/dist/services/visualization.js.map +1 -0
  72. package/dist/utils/sentryTrpc.d.ts +3 -0
  73. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  74. package/dist/utils/sentryTrpc.js +26 -0
  75. package/dist/utils/sentryTrpc.js.map +1 -0
  76. package/package.json +1 -1
  77. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  78. package/prisma/schema.prisma +24 -0
  79. package/src/index.ts +30 -4
  80. package/src/instrument.ts +8 -2
  81. package/src/lib/fileUpload.ts +49 -0
  82. package/src/pipelines/generateVisualization.ts +107 -0
  83. package/src/routers/_app.ts +2 -0
  84. package/src/routers/admin.ts +72 -0
  85. package/src/routers/class.ts +14 -1
  86. package/src/routers/visualization.ts +60 -0
  87. package/src/services/adminClass.ts +222 -0
  88. package/src/services/adminInstitution.ts +9 -2
  89. package/src/services/adminMetrics.ts +35 -0
  90. package/src/services/adminSpam.ts +224 -0
  91. package/src/services/adminSql.ts +214 -0
  92. package/src/services/adminTelemetry.ts +194 -0
  93. package/src/services/adminUser.ts +61 -9
  94. package/src/services/auth.ts +8 -2
  95. package/src/services/fees.ts +5 -1
  96. package/src/services/module.ts +29 -2
  97. package/src/services/visualization.ts +147 -0
  98. package/src/utils/sentryTrpc.ts +25 -0
  99. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  100. package/tests/unit/services/adminClass.test.ts +102 -0
  101. package/tests/unit/services/adminSpam.test.ts +108 -0
  102. package/tests/unit/services/adminSql.test.ts +56 -0
  103. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  104. package/tests/unit/services/adminUser.test.ts +2 -0
  105. package/tests/unit/services/visualization.test.ts +130 -0
  106. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -1,6 +1,7 @@
1
1
  import type { Prisma } from '@prisma/client';
2
2
  import { TRPCError } from '@trpc/server';
3
3
  import { prisma } from '../lib/prisma.js';
4
+ import { classifyEmail } from './adminSpam.js';
4
5
 
5
6
  export async function listSchoolsForAdmin(input: {
6
7
  page: number;
@@ -159,7 +160,10 @@ export async function listSchoolDevelopmentProgramsForAdmin(input: {
159
160
  ]);
160
161
 
161
162
  return {
162
- items,
163
+ items: items.map((item) => ({
164
+ ...item,
165
+ signals: classifyEmail(item.contactEmail),
166
+ })),
163
167
  total,
164
168
  page: input.page,
165
169
  pageSize: input.pageSize,
@@ -183,7 +187,10 @@ export async function listEarlyAccessRequestsForAdmin(input: {
183
187
  ]);
184
188
 
185
189
  return {
186
- items,
190
+ items: items.map((item) => ({
191
+ ...item,
192
+ signals: classifyEmail(item.email),
193
+ })),
187
194
  total,
188
195
  page: input.page,
189
196
  pageSize: input.pageSize,
@@ -1,13 +1,31 @@
1
1
  import { prisma } from '../lib/prisma.js';
2
+ import { suspectedUserWhere } from './adminSpam.js';
3
+
4
+ async function countActiveUsersSince(since: Date) {
5
+ const rows = await prisma.session.groupBy({
6
+ by: ['userId'],
7
+ where: { type: 'AUTH', createdAt: { gte: since }, userId: { not: null } },
8
+ });
9
+ return rows.length;
10
+ }
2
11
 
3
12
  export async function getOverviewMetricsForAdmin() {
4
13
  const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
14
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
15
+ const suspected = suspectedUserWhere();
5
16
 
6
17
  const [
7
18
  schools,
8
19
  users,
9
20
  verifiedUsers,
21
+ suspectedUsers,
22
+ signupsLast24h,
23
+ signupsLast7d,
24
+ activeUsersLast24h,
25
+ activeUsersLast7d,
10
26
  classes,
27
+ activeClassesLast7d,
28
+ submissionsLast24h,
11
29
  departments,
12
30
  runJobsByStatus,
13
31
  failedRunJobsLast24h,
@@ -17,7 +35,16 @@ export async function getOverviewMetricsForAdmin() {
17
35
  prisma.school.count(),
18
36
  prisma.user.count(),
19
37
  prisma.user.count({ where: { verified: true } }),
38
+ prisma.user.count({ where: suspected }),
39
+ prisma.userProfile.count({ where: { createdAt: { gte: dayAgo } } }),
40
+ prisma.userProfile.count({ where: { createdAt: { gte: weekAgo } } }),
41
+ countActiveUsersSince(dayAgo),
42
+ countActiveUsersSince(weekAgo),
20
43
  prisma.class.count(),
44
+ prisma.class.count({
45
+ where: { assignments: { some: { createdAt: { gte: weekAgo } } } },
46
+ }),
47
+ prisma.submission.count({ where: { submittedAt: { gte: dayAgo } } }),
21
48
  prisma.department.count(),
22
49
  prisma.runJob.groupBy({ by: ['status'], _count: { _all: true } }),
23
50
  prisma.runJob.count({
@@ -31,6 +58,14 @@ export async function getOverviewMetricsForAdmin() {
31
58
  schools,
32
59
  users,
33
60
  verifiedUsers,
61
+ suspectedUsers,
62
+ cleanUsers: users - suspectedUsers,
63
+ signupsLast24h,
64
+ signupsLast7d,
65
+ activeUsersLast24h,
66
+ activeUsersLast7d,
67
+ activeClassesLast7d,
68
+ submissionsLast24h,
34
69
  classes,
35
70
  departments,
36
71
  runJobs: {
@@ -0,0 +1,224 @@
1
+ import type { Prisma } from '@prisma/client';
2
+ import { prisma } from '../lib/prisma.js';
3
+
4
+ export type SpamReason =
5
+ | 'BLOCKED_DOMAIN'
6
+ | 'BOT_PATTERN'
7
+ | 'DISPOSABLE_DOMAIN'
8
+ | 'NEVER_VERIFIED_INACTIVE';
9
+
10
+ export type SpamSignals = {
11
+ suspected: boolean;
12
+ reasons: SpamReason[];
13
+ };
14
+
15
+ const DEFAULT_BLOCKED_DOMAINS = ['riverside.edu'];
16
+
17
+ const DEFAULT_BOT_PATTERNS = ['devin', 'automation-bot', 'qa-bot'];
18
+
19
+ const DEFAULT_DISPOSABLE_DOMAINS = [
20
+ 'mailinator.com',
21
+ 'guerrillamail.com',
22
+ 'yopmail.com',
23
+ 'tempmail.com',
24
+ 'temp-mail.org',
25
+ '10minutemail.com',
26
+ 'trashmail.com',
27
+ 'sharklasers.com',
28
+ 'dispostable.com',
29
+ ];
30
+
31
+ function fromEnv(name: string, fallback: string[]): string[] {
32
+ const raw = process.env[name];
33
+ if (!raw) return fallback;
34
+ const parsed = raw
35
+ .split(',')
36
+ .map((entry) => entry.trim().toLowerCase())
37
+ .filter(Boolean);
38
+ return parsed.length > 0 ? parsed : fallback;
39
+ }
40
+
41
+ export function spamRules() {
42
+ return {
43
+ blockedDomains: fromEnv('ADMIN_SPAM_DOMAINS', DEFAULT_BLOCKED_DOMAINS),
44
+ botPatterns: fromEnv('ADMIN_SPAM_BOT_PATTERNS', DEFAULT_BOT_PATTERNS),
45
+ disposableDomains: fromEnv(
46
+ 'ADMIN_SPAM_DISPOSABLE_DOMAINS',
47
+ DEFAULT_DISPOSABLE_DOMAINS,
48
+ ),
49
+ };
50
+ }
51
+
52
+ function domainOf(email: string): string {
53
+ const at = email.lastIndexOf('@');
54
+ return at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
55
+ }
56
+
57
+ function matchesDomain(domain: string, listed: string): boolean {
58
+ return domain === listed || domain.endsWith(`.${listed}`);
59
+ }
60
+
61
+ export type ClassifiableUser = {
62
+ username: string;
63
+ email: string;
64
+ verified: boolean;
65
+ _count?: { teacherIn: number; studentIn: number; submissions?: number };
66
+ };
67
+
68
+ export function classifyEmail(
69
+ email: string | null | undefined,
70
+ username?: string | null,
71
+ ): SpamSignals {
72
+ const rules = spamRules();
73
+ const normalisedEmail = (email ?? '').toLowerCase();
74
+ const normalisedUsername = (username ?? '').toLowerCase();
75
+ const domain = domainOf(normalisedEmail);
76
+ const reasons: SpamReason[] = [];
77
+
78
+ if (rules.blockedDomains.some((listed) => matchesDomain(domain, listed))) {
79
+ reasons.push('BLOCKED_DOMAIN');
80
+ }
81
+ if (rules.disposableDomains.some((listed) => matchesDomain(domain, listed))) {
82
+ reasons.push('DISPOSABLE_DOMAIN');
83
+ }
84
+ if (
85
+ rules.botPatterns.some(
86
+ (pattern) =>
87
+ normalisedEmail.includes(pattern) ||
88
+ (normalisedUsername.length > 0 && normalisedUsername.includes(pattern)),
89
+ )
90
+ ) {
91
+ reasons.push('BOT_PATTERN');
92
+ }
93
+
94
+ return { suspected: reasons.length > 0, reasons };
95
+ }
96
+
97
+ export function classifyUser(user: ClassifiableUser): SpamSignals {
98
+ const reasons = [...classifyEmail(user.email, user.username).reasons];
99
+ const counts = user._count;
100
+ if (
101
+ !user.verified &&
102
+ counts &&
103
+ counts.teacherIn === 0 &&
104
+ counts.studentIn === 0 &&
105
+ (counts.submissions ?? 0) === 0
106
+ ) {
107
+ reasons.push('NEVER_VERIFIED_INACTIVE');
108
+ }
109
+
110
+ return { suspected: reasons.length > 0, reasons };
111
+ }
112
+
113
+ /**
114
+ * Prisma filter matching the rule-based part of {@link classifyUser}. The
115
+ * `NEVER_VERIFIED_INACTIVE` reason is intentionally excluded: brand new real
116
+ * signups look identical to it, so it only annotates rows and never drives
117
+ * list filtering or counts.
118
+ */
119
+ export function suspectedUserWhere(): Prisma.UserWhereInput {
120
+ const rules = spamRules();
121
+ const domains = [...rules.blockedDomains, ...rules.disposableDomains];
122
+
123
+ return {
124
+ OR: [
125
+ ...domains.map((domain) => ({
126
+ email: { endsWith: `@${domain}`, mode: 'insensitive' as const },
127
+ })),
128
+ ...domains.map((domain) => ({
129
+ email: { endsWith: `.${domain}`, mode: 'insensitive' as const },
130
+ })),
131
+ ...rules.botPatterns.flatMap((pattern) => [
132
+ { username: { contains: pattern, mode: 'insensitive' as const } },
133
+ { email: { contains: pattern, mode: 'insensitive' as const } },
134
+ ]),
135
+ ],
136
+ };
137
+ }
138
+
139
+ export function notSuspectedUserWhere(): Prisma.UserWhereInput {
140
+ return { NOT: suspectedUserWhere() };
141
+ }
142
+
143
+ export async function countSuspectedUsers(): Promise<number> {
144
+ return prisma.user.count({ where: suspectedUserWhere() });
145
+ }
146
+
147
+ type DomainRow = { domain: string | null; total: bigint; verified: bigint };
148
+
149
+ export async function listUserEmailDomainsForAdmin(input: {
150
+ limit: number;
151
+ suspectedOnly: boolean;
152
+ }) {
153
+ const rows = await prisma.$queryRaw<DomainRow[]>`
154
+ SELECT lower(split_part(email, '@', 2)) AS domain,
155
+ count(*) AS total,
156
+ count(*) FILTER (WHERE verified) AS verified
157
+ FROM "User"
158
+ GROUP BY 1
159
+ ORDER BY count(*) DESC, 1 ASC
160
+ LIMIT ${Math.min(input.limit, 200)}
161
+ `;
162
+
163
+ const rules = spamRules();
164
+ const items = rows.map((row) => {
165
+ const domain = row.domain ?? '';
166
+ const suspected =
167
+ rules.blockedDomains.some((listed) => matchesDomain(domain, listed)) ||
168
+ rules.disposableDomains.some((listed) => matchesDomain(domain, listed));
169
+ return {
170
+ domain: domain || '(none)',
171
+ total: Number(row.total),
172
+ verified: Number(row.verified),
173
+ suspected,
174
+ };
175
+ });
176
+
177
+ return {
178
+ items: input.suspectedOnly ? items.filter((item) => item.suspected) : items,
179
+ };
180
+ }
181
+
182
+ export async function getSpamOverviewForAdmin() {
183
+ const rules = spamRules();
184
+ const suspectedWhere = suspectedUserWhere();
185
+
186
+ const [
187
+ totalUsers,
188
+ suspectedUsers,
189
+ suspectedVerified,
190
+ domains,
191
+ recent,
192
+ ] = await Promise.all([
193
+ prisma.user.count(),
194
+ prisma.user.count({ where: suspectedWhere }),
195
+ prisma.user.count({ where: { AND: [suspectedWhere, { verified: true }] } }),
196
+ listUserEmailDomainsForAdmin({ limit: 200, suspectedOnly: true }),
197
+ prisma.user.findMany({
198
+ where: suspectedWhere,
199
+ orderBy: { username: 'asc' },
200
+ take: 50,
201
+ select: {
202
+ id: true,
203
+ username: true,
204
+ email: true,
205
+ verified: true,
206
+ profile: { select: { createdAt: true } },
207
+ _count: { select: { teacherIn: true, studentIn: true, submissions: true } },
208
+ },
209
+ }),
210
+ ]);
211
+
212
+ return {
213
+ rules,
214
+ totalUsers,
215
+ suspectedUsers,
216
+ suspectedVerified,
217
+ cleanUsers: totalUsers - suspectedUsers,
218
+ domains: domains.items,
219
+ recent: recent.map((user) => ({
220
+ ...user,
221
+ signals: classifyUser(user),
222
+ })),
223
+ };
224
+ }
@@ -0,0 +1,214 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { prisma } from '../lib/prisma.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Read-only SQL console backing `admin.runSql`.
7
+ *
8
+ * Three independent guards, because a single one is not enough:
9
+ * 1. the statement is parsed/validated here (single read statement only),
10
+ * 2. it runs inside a `READ ONLY` transaction so the database rejects writes,
11
+ * 3. a statement timeout and row cap bound the blast radius of a bad query.
12
+ */
13
+
14
+ const READ_STARTERS = ['select', 'with', 'explain', 'show', 'table', 'values'];
15
+
16
+ /** Statement keywords that must never appear, including inside CTEs. */
17
+ const FORBIDDEN_KEYWORDS = [
18
+ 'insert',
19
+ 'update',
20
+ 'delete',
21
+ 'merge',
22
+ 'upsert',
23
+ 'drop',
24
+ 'alter',
25
+ 'create',
26
+ 'truncate',
27
+ 'grant',
28
+ 'revoke',
29
+ 'comment',
30
+ 'copy',
31
+ 'vacuum',
32
+ 'analyze',
33
+ 'reindex',
34
+ 'cluster',
35
+ 'refresh',
36
+ 'lock',
37
+ 'listen',
38
+ 'notify',
39
+ 'call',
40
+ 'do',
41
+ 'set',
42
+ 'reset',
43
+ 'begin',
44
+ 'commit',
45
+ 'rollback',
46
+ 'savepoint',
47
+ 'prepare',
48
+ 'execute',
49
+ 'deallocate',
50
+ 'discard',
51
+ 'import',
52
+ 'security',
53
+ 'pg_read_file',
54
+ 'pg_read_binary_file',
55
+ 'pg_ls_dir',
56
+ 'dblink',
57
+ 'pg_sleep',
58
+ ];
59
+
60
+ export const SQL_MAX_ROWS = 500;
61
+ export const SQL_TIMEOUT_MS = 10_000;
62
+
63
+ function stripCommentsAndStrings(sql: string): string {
64
+ return sql
65
+ .replace(/--[^\n]*/g, ' ')
66
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
67
+ .replace(/'(?:[^']|'')*'/g, "''")
68
+ .replace(/"(?:[^"]|"")*"/g, '""');
69
+ }
70
+
71
+ function badRequest(message: string): TRPCError {
72
+ return new TRPCError({ code: 'BAD_REQUEST', message });
73
+ }
74
+
75
+ /**
76
+ * Validates that `sql` is a single read-only statement and returns it without
77
+ * its trailing semicolon. Throws a `BAD_REQUEST` otherwise.
78
+ */
79
+ export function assertReadOnlySql(sql: string): string {
80
+ const statement = sql.trim().replace(/;\s*$/, '').trim();
81
+ if (!statement) throw badRequest('Query is empty.');
82
+
83
+ const sanitized = stripCommentsAndStrings(statement).toLowerCase();
84
+
85
+ if (sanitized.includes(';')) {
86
+ throw badRequest('Only a single statement may be executed.');
87
+ }
88
+
89
+ const firstWord = sanitized.trim().split(/\s+/)[0]?.replace(/^\(+/, '') ?? '';
90
+ if (!READ_STARTERS.includes(firstWord)) {
91
+ throw badRequest(
92
+ `Only ${READ_STARTERS.map((word) => word.toUpperCase()).join('/')} queries are allowed.`,
93
+ );
94
+ }
95
+
96
+ const forbidden = FORBIDDEN_KEYWORDS.find((keyword) =>
97
+ new RegExp(`\\b${keyword}\\b`).test(sanitized),
98
+ );
99
+ if (forbidden) {
100
+ throw badRequest(`Keyword "${forbidden.toUpperCase()}" is not allowed in the SQL console.`);
101
+ }
102
+
103
+ return statement;
104
+ }
105
+
106
+ /** Wraps row-returning statements so the row cap is enforced by the database. */
107
+ export function buildBoundedQuery(statement: string, limit: number): string {
108
+ const firstWord = stripCommentsAndStrings(statement).trim().toLowerCase().split(/\s+/)[0] ?? '';
109
+ if (firstWord === 'explain' || firstWord === 'show') return statement;
110
+ return `SELECT * FROM (${statement}) AS admin_sql_console LIMIT ${limit}`;
111
+ }
112
+
113
+ /** JSON-safe values: Prisma returns bigints, dates, buffers and Decimals raw. */
114
+ export function serializeValue(value: unknown): unknown {
115
+ if (value === null || value === undefined) return null;
116
+ if (typeof value === 'bigint') return value.toString();
117
+ if (value instanceof Date) return value.toISOString();
118
+ if (Buffer.isBuffer(value)) return `\\x${value.toString('hex')}`;
119
+ if (Array.isArray(value)) return value.map(serializeValue);
120
+ if (typeof value === 'object') {
121
+ const source = value as Record<string, unknown>;
122
+ if (typeof source.toFixed === 'function') return String(value);
123
+ return Object.fromEntries(
124
+ Object.entries(source).map(([key, entry]) => [key, serializeValue(entry)]),
125
+ );
126
+ }
127
+ return value;
128
+ }
129
+
130
+ export type AdminSqlResult = {
131
+ columns: string[];
132
+ rows: Record<string, unknown>[];
133
+ rowCount: number;
134
+ truncated: boolean;
135
+ durationMs: number;
136
+ };
137
+
138
+ export async function runAdminSql(input: {
139
+ sql: string;
140
+ limit?: number;
141
+ userId?: string;
142
+ }): Promise<AdminSqlResult> {
143
+ const limit = Math.min(Math.max(input.limit ?? SQL_MAX_ROWS, 1), SQL_MAX_ROWS);
144
+ const statement = assertReadOnlySql(input.sql);
145
+ const query = buildBoundedQuery(statement, limit + 1);
146
+
147
+ logger.warn('Admin SQL console query', { userId: input.userId, sql: statement, limit });
148
+
149
+ const started = Date.now();
150
+ let rows: Record<string, unknown>[];
151
+ try {
152
+ rows = await prisma.$transaction(async (tx) => {
153
+ await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY');
154
+ await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = ${SQL_TIMEOUT_MS}`);
155
+ return (await tx.$queryRawUnsafe(query)) as Record<string, unknown>[];
156
+ });
157
+ } catch (error) {
158
+ throw new TRPCError({
159
+ code: 'BAD_REQUEST',
160
+ message: error instanceof Error ? error.message : 'Query failed',
161
+ cause: error,
162
+ });
163
+ }
164
+
165
+ const durationMs = Date.now() - started;
166
+ const truncated = rows.length > limit;
167
+ const capped = truncated ? rows.slice(0, limit) : rows;
168
+
169
+ return {
170
+ columns: capped.length > 0 ? Object.keys(capped[0]) : [],
171
+ rows: capped.map(
172
+ (row) =>
173
+ Object.fromEntries(
174
+ Object.entries(row).map(([key, value]) => [key, serializeValue(value)]),
175
+ ) as Record<string, unknown>,
176
+ ),
177
+ rowCount: capped.length,
178
+ truncated,
179
+ durationMs,
180
+ };
181
+ }
182
+
183
+ export type AdminSqlTable = {
184
+ table: string;
185
+ columns: { name: string; type: string; nullable: boolean }[];
186
+ };
187
+
188
+ /** Schema browser for the console sidebar. */
189
+ export async function listSqlSchemaForAdmin(): Promise<{ tables: AdminSqlTable[] }> {
190
+ const rows = (await prisma.$queryRawUnsafe(`
191
+ SELECT table_name, column_name, data_type, is_nullable
192
+ FROM information_schema.columns
193
+ WHERE table_schema = 'public'
194
+ ORDER BY table_name, ordinal_position
195
+ `)) as {
196
+ table_name: string;
197
+ column_name: string;
198
+ data_type: string;
199
+ is_nullable: string;
200
+ }[];
201
+
202
+ const tables = new Map<string, AdminSqlTable>();
203
+ for (const row of rows) {
204
+ const table = tables.get(row.table_name) ?? { table: row.table_name, columns: [] };
205
+ table.columns.push({
206
+ name: row.column_name,
207
+ type: row.data_type,
208
+ nullable: row.is_nullable === 'YES',
209
+ });
210
+ tables.set(row.table_name, table);
211
+ }
212
+
213
+ return { tables: [...tables.values()] };
214
+ }