@graphoria/server 0.2.3 → 0.4.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.
- package/README.md +29 -20
- package/dist/src/ai/mcp/index.d.ts +9 -2
- package/dist/src/ai/mcp/index.d.ts.map +1 -1
- package/dist/src/ai/tools/core.d.ts +6 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/core.test.d.ts +2 -0
- package/dist/src/ai/tools/core.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
- package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/fragments.d.ts +6 -0
- package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.d.ts +18 -0
- package/dist/src/authentication/capabilities.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.test.d.ts +2 -0
- package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/secrets.d.ts +9 -0
- package/dist/src/authentication/secrets.d.ts.map +1 -0
- package/dist/src/authentication/secrets.test.d.ts +2 -0
- package/dist/src/authentication/secrets.test.d.ts.map +1 -0
- package/dist/src/config/types/auth.d.ts +8 -0
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +4 -0
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/operation.test.d.ts +2 -0
- package/dist/src/config/types/operation.test.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.d.ts +27 -0
- package/dist/src/configuration/crossReferences.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.test.d.ts +2 -0
- package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +70 -6
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +41 -3
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts +7 -5
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +29 -0
- package/dist/src/console/session.d.ts.map +1 -0
- package/dist/src/console/session.test.d.ts +2 -0
- package/dist/src/console/session.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +12 -4
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +7 -3
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +5 -4
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
- package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +30 -7
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/format.d.ts +2 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +17 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/format.d.ts +2 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +26 -7
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +2 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/audit.d.ts +27 -0
- package/dist/src/logging/audit.d.ts.map +1 -0
- package/dist/src/logging/audit.test.d.ts +2 -0
- package/dist/src/logging/audit.test.d.ts.map +1 -0
- package/dist/src/singletons/env.d.ts +27 -4
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queryTimeout.d.ts +3 -0
- package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
- package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
- package/dist/src/subscriptions/types.d.ts +4 -1
- package/dist/src/subscriptions/types.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +97 -12
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +12 -0
- package/dist/src/types/zod/auth.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +28 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts +65 -0
- package/dist/src/utils/rateLimit.d.ts.map +1 -0
- package/dist/src/utils/rateLimit.test.d.ts +2 -0
- package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +6 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/sessionVariables.d.ts +2 -0
- package/dist/src/utils/sessionVariables.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +548 -685
- package/src/ai/mcp/index.ts +19 -14
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -0
- package/src/authentication/capabilities.ts +54 -0
- package/src/authentication/index.ts +3 -3
- package/src/authentication/jwt.ts +30 -25
- package/src/authentication/paseto.ts +36 -26
- package/src/authentication/secrets.ts +30 -0
- package/src/config/types/auth.ts +7 -0
- package/src/config/types/db.ts +7 -0
- package/src/config/types/operation.ts +4 -0
- package/src/configuration/crossReferences.ts +340 -0
- package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
- package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/gqlAuthOperations.ts +26 -0
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +97 -15
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +31 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/generateOpenAPI.ts +61 -11
- package/src/configuration/rest/handleRESTRequestFactory.ts +32 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +102 -16
- package/src/console/session.ts +129 -0
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +13 -4
- package/src/databases/core/function-mapping.ts +5 -1
- package/src/databases/core/procedure-arguments.ts +25 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -17
- package/src/databases/engines/mssql/format.ts +3 -1
- package/src/databases/engines/mssql/query/index.ts +12 -2
- package/src/databases/engines/mysql/connection.ts +40 -20
- package/src/databases/engines/mysql/format.ts +3 -1
- package/src/databases/engines/mysql/query/index.ts +25 -4
- package/src/databases/engines/postgresql/connection.ts +86 -22
- package/src/databases/engines/postgresql/format.ts +3 -1
- package/src/databases/engines/postgresql/query/index.ts +12 -3
- package/src/databases/high-level-operations.ts +13 -2
- package/src/index.ts +127 -19
- package/src/logging/audit.ts +86 -0
- package/src/singletons/queryTimeout.ts +20 -0
- package/src/subscriptions/strategies/database.ts +19 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/subscriptions/utils/polling.ts +6 -3
- package/src/types/env.ts +47 -9
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +16 -0
- package/src/utils/sessionVariables.ts +2 -0
package/src/console/api.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
import type { ConsoleScope } from "./session";
|
|
2
|
+
import type { TokenService } from "../authentication/types";
|
|
1
3
|
import type { AnalyzedConfiguration } from "../configuration";
|
|
2
4
|
import type { Configuration } from "../types/configuration";
|
|
3
5
|
import type { Env } from "../types/env";
|
|
4
|
-
import type {
|
|
6
|
+
import type { RateLimiter } from "../utils/rateLimit";
|
|
7
|
+
import type { BunRequest, SQL } from "bun";
|
|
5
8
|
import type { ConnectionPool } from "mssql";
|
|
6
9
|
|
|
10
|
+
import { createConsoleSessions } from "./session";
|
|
7
11
|
import { getTags } from "../configuration/rest/generateOpenAPI";
|
|
12
|
+
import { audit } from "../logging/audit";
|
|
8
13
|
import { getCronJobs } from "../singletons/cron";
|
|
9
14
|
import { databasesConnections } from "../singletons/databases";
|
|
10
15
|
import { queueManager } from "../singletons/queues";
|
|
11
|
-
import {
|
|
16
|
+
import { resolveClientAddress } from "../utils/rateLimit";
|
|
17
|
+
import { S200, S400, S401, S403, S404, S429 } from "../utils/responses";
|
|
12
18
|
|
|
13
19
|
type ConsoleRoutesFactoryOptions = {
|
|
14
20
|
env: Env;
|
|
@@ -16,10 +22,16 @@ type ConsoleRoutesFactoryOptions = {
|
|
|
16
22
|
prefixes: Record<string, string>;
|
|
17
23
|
projectConfiguration: Configuration;
|
|
18
24
|
analyzedConfiguration: AnalyzedConfiguration;
|
|
19
|
-
|
|
25
|
+
tokenService: TokenService;
|
|
26
|
+
rateLimiter?: RateLimiter | undefined;
|
|
20
27
|
};
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
// Bun hands a route handler the server as its second argument; the parameter is
|
|
30
|
+
// optional only because the type is also used for handlers that ignore it.
|
|
31
|
+
type ConsoleRouteHandler = (
|
|
32
|
+
req: BunRequest,
|
|
33
|
+
server?: Bun.Server<unknown>,
|
|
34
|
+
) => Response | Promise<Response>;
|
|
23
35
|
|
|
24
36
|
const PING_TIMEOUT_MS = 2000;
|
|
25
37
|
|
|
@@ -52,15 +64,30 @@ export const consoleRoutesFactory = ({
|
|
|
52
64
|
prefixes,
|
|
53
65
|
projectConfiguration,
|
|
54
66
|
analyzedConfiguration,
|
|
55
|
-
|
|
67
|
+
tokenService,
|
|
68
|
+
rateLimiter,
|
|
56
69
|
}: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
|
|
70
|
+
const sessions = createConsoleSessions({ env, consolePath, tokenService });
|
|
71
|
+
|
|
72
|
+
const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
|
|
73
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy);
|
|
74
|
+
|
|
57
75
|
const guarded =
|
|
58
|
-
(
|
|
59
|
-
|
|
76
|
+
(
|
|
77
|
+
handler: (
|
|
78
|
+
req: BunRequest,
|
|
79
|
+
server: Bun.Server<unknown> | undefined,
|
|
80
|
+
scope: ConsoleScope,
|
|
81
|
+
) => object | Promise<object>,
|
|
82
|
+
required: ConsoleScope = "read",
|
|
83
|
+
): ConsoleRouteHandler =>
|
|
84
|
+
async (req: BunRequest, server?: Bun.Server<unknown>) => {
|
|
60
85
|
try {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
86
|
+
const scope = await sessions.authorize(req);
|
|
87
|
+
if (!scope) return new S404({ error: "Not Found" });
|
|
88
|
+
if (required === "write" && scope !== "write")
|
|
89
|
+
return new S403({ errors: [{ message: "Console session is read-only" }] });
|
|
90
|
+
return new S200(await handler(req, server, scope));
|
|
64
91
|
} catch (error) {
|
|
65
92
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
66
93
|
}
|
|
@@ -78,10 +105,58 @@ export const consoleRoutesFactory = ({
|
|
|
78
105
|
new S200({
|
|
79
106
|
name: projectConfiguration.name,
|
|
80
107
|
version: projectConfiguration.version,
|
|
81
|
-
adminSecretHeader: env.admin.header,
|
|
82
108
|
}),
|
|
83
109
|
},
|
|
84
110
|
|
|
111
|
+
[`${base}/login`]: {
|
|
112
|
+
...cors,
|
|
113
|
+
POST: async (req, server) => {
|
|
114
|
+
// Keyed by address against the anonymous ceiling: there is no session
|
|
115
|
+
// yet, and the attempt is charged before the secret is compared, so a
|
|
116
|
+
// guesser cannot spend attempts for free.
|
|
117
|
+
const ip = clientAddress(req, server);
|
|
118
|
+
const limit = await rateLimiter?.check(null, ip);
|
|
119
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
120
|
+
|
|
121
|
+
const actor = { type: "admin_secret", ip } as const;
|
|
122
|
+
try {
|
|
123
|
+
const { expiresIn, scope, superset } = await sessions.login(req);
|
|
124
|
+
audit().emit({
|
|
125
|
+
action: "console.login",
|
|
126
|
+
outcome: "success",
|
|
127
|
+
actor: { ...actor, scope: superset ? "all" : `console:${scope}` },
|
|
128
|
+
target: { kind: "console" },
|
|
129
|
+
});
|
|
130
|
+
return new S200({ expiresIn, scope });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
audit().emit({
|
|
133
|
+
action: "console.login",
|
|
134
|
+
outcome: "failure",
|
|
135
|
+
actor,
|
|
136
|
+
target: { kind: "console" },
|
|
137
|
+
reason: (error as Error)?.message,
|
|
138
|
+
});
|
|
139
|
+
// One message for a bad secret and for a malformed body alike: the
|
|
140
|
+
// caller learns nothing about which it was.
|
|
141
|
+
return new S401({ errors: [{ message: "Invalid admin secret" }] });
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
[`${base}/logout`]: {
|
|
147
|
+
...cors,
|
|
148
|
+
POST: async (req, server) => {
|
|
149
|
+
if (await sessions.logout(req)) {
|
|
150
|
+
audit().emit({
|
|
151
|
+
action: "console.logout",
|
|
152
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
153
|
+
target: { kind: "console" },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return new S200({ ok: true });
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
85
160
|
[`${base}/tables`]: {
|
|
86
161
|
...cors,
|
|
87
162
|
GET: guarded(() => ({
|
|
@@ -223,7 +298,7 @@ export const consoleRoutesFactory = ({
|
|
|
223
298
|
|
|
224
299
|
[`${base}/config`]: {
|
|
225
300
|
...cors,
|
|
226
|
-
GET: guarded(() => ({
|
|
301
|
+
GET: guarded((_req, _server, scope) => ({
|
|
227
302
|
name: projectConfiguration.name,
|
|
228
303
|
version: projectConfiguration.version,
|
|
229
304
|
prefixes,
|
|
@@ -233,12 +308,13 @@ export const consoleRoutesFactory = ({
|
|
|
233
308
|
mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
|
|
234
309
|
cors: env.enableCors,
|
|
235
310
|
},
|
|
311
|
+
session: { scope },
|
|
236
312
|
})),
|
|
237
313
|
},
|
|
238
314
|
|
|
239
315
|
[`${base}/queues/publish`]: {
|
|
240
316
|
...cors,
|
|
241
|
-
POST: guarded(async (req) => {
|
|
317
|
+
POST: guarded(async (req, server) => {
|
|
242
318
|
const { publisher, message, key } = (await req.json()) as {
|
|
243
319
|
publisher?: string;
|
|
244
320
|
message?: string | object;
|
|
@@ -247,13 +323,18 @@ export const consoleRoutesFactory = ({
|
|
|
247
323
|
if (!publisher || message == null) throw new Error("publisher and message are required");
|
|
248
324
|
if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
|
|
249
325
|
throw new Error(`Unknown publisher "${publisher}"`);
|
|
326
|
+
audit().emit({
|
|
327
|
+
action: "console.queue.publish",
|
|
328
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
329
|
+
target: { kind: "publisher", name: publisher, key },
|
|
330
|
+
});
|
|
250
331
|
return { ok: await queueManager!.sendMessage(publisher, message, key) };
|
|
251
|
-
}),
|
|
332
|
+
}, "write"),
|
|
252
333
|
},
|
|
253
334
|
|
|
254
335
|
[`${base}/cron`]: {
|
|
255
336
|
...cors,
|
|
256
|
-
POST: guarded(async (req) => {
|
|
337
|
+
POST: guarded(async (req, server) => {
|
|
257
338
|
const { name, action } = (await req.json()) as { name?: string; action?: string };
|
|
258
339
|
const cron = getCronJobs();
|
|
259
340
|
if (!cron) throw new Error("Cron is not enabled");
|
|
@@ -261,10 +342,15 @@ export const consoleRoutesFactory = ({
|
|
|
261
342
|
if (action !== "trigger" && action !== "pause" && action !== "resume")
|
|
262
343
|
throw new Error(`Unknown action "${action}"`);
|
|
263
344
|
if (!cron.getJob(name)) throw new Error(`Unknown job "${name}"`);
|
|
345
|
+
audit().emit({
|
|
346
|
+
action: `console.cron.${action}`,
|
|
347
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
348
|
+
target: { kind: "cron", name },
|
|
349
|
+
});
|
|
264
350
|
if (action === "trigger") await cron.trigger(name);
|
|
265
351
|
else cron[action](name);
|
|
266
352
|
return { ok: true };
|
|
267
|
-
}),
|
|
353
|
+
}, "write"),
|
|
268
354
|
},
|
|
269
355
|
};
|
|
270
356
|
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { BunRequest } from "bun";
|
|
2
|
+
import type { TokenService } from "../authentication/types";
|
|
3
|
+
import type { Env } from "../types/env";
|
|
4
|
+
|
|
5
|
+
import { createCapabilityAuthorizer } from "../authentication/capabilities";
|
|
6
|
+
import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
|
|
7
|
+
import { logger } from "../logging";
|
|
8
|
+
|
|
9
|
+
export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
|
|
10
|
+
|
|
11
|
+
// Separates a console session from an access or refresh token signed with the
|
|
12
|
+
// same key: neither can be presented where the other is expected.
|
|
13
|
+
const CONSOLE_AUDIENCE = "console";
|
|
14
|
+
const CONSOLE_SUBJECT = "console";
|
|
15
|
+
|
|
16
|
+
/** `read` sees state; `write` may also publish to queues and control cron. */
|
|
17
|
+
export type ConsoleScope = "read" | "write";
|
|
18
|
+
|
|
19
|
+
const isScope = (value: unknown): value is ConsoleScope => value === "read" || value === "write";
|
|
20
|
+
|
|
21
|
+
export type ConsoleSessions = {
|
|
22
|
+
/**
|
|
23
|
+
* Exchange a secret for a session cookie. Throws if none matches. `superset`
|
|
24
|
+
* is true when it was the admin secret rather than a console credential.
|
|
25
|
+
*/
|
|
26
|
+
login(req: BunRequest): Promise<{ expiresIn: number; scope: ConsoleScope; superset: boolean }>;
|
|
27
|
+
/** The scope of a live, unrevoked console session cookie; null when there is none. */
|
|
28
|
+
authorize(req: BunRequest): Promise<ConsoleScope | null>;
|
|
29
|
+
/** Resolves true when a live session was revoked; the cookie is cleared either way. */
|
|
30
|
+
logout(req: BunRequest): Promise<boolean>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ConsoleSessionsOptions = {
|
|
34
|
+
env: Env;
|
|
35
|
+
consolePath: string;
|
|
36
|
+
tokenService: TokenService;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const createConsoleSessions = ({
|
|
40
|
+
env,
|
|
41
|
+
consolePath,
|
|
42
|
+
tokenService,
|
|
43
|
+
}: ConsoleSessionsOptions): ConsoleSessions => {
|
|
44
|
+
const log = logger("console");
|
|
45
|
+
const authorizeCapability = createCapabilityAuthorizer(env);
|
|
46
|
+
const expiresIn = env.console.sessionExpiresIn;
|
|
47
|
+
const sessionMs = parseDurationToMs(expiresIn);
|
|
48
|
+
|
|
49
|
+
// Revoked JTIs, held in this process only: the console mints its own tokens
|
|
50
|
+
// and never writes to the Redis JTI store, so a logout is honoured by the
|
|
51
|
+
// worker that served it and by no other. An entry is dropped once the token
|
|
52
|
+
// it revokes would have expired on its own.
|
|
53
|
+
const revoked = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
const prune = () => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const [jti, expiresAt] of revoked) if (expiresAt <= now) revoked.delete(jti);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const cookieOptions = {
|
|
61
|
+
httpOnly: true,
|
|
62
|
+
secure: true,
|
|
63
|
+
sameSite: "strict",
|
|
64
|
+
path: consolePath,
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
const readSession = async (req: BunRequest) => {
|
|
68
|
+
const cookie = req.cookies?.get(CONSOLE_SESSION_COOKIE);
|
|
69
|
+
if (!cookie) return null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
|
|
73
|
+
if (payload.role !== env.superadmin.role) return null;
|
|
74
|
+
const scope = payload.claims?.scope;
|
|
75
|
+
if (!isScope(scope)) return null;
|
|
76
|
+
return { ...payload, scope };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
login: async (req) => {
|
|
84
|
+
const { secret: submitted } = (await req.json()) as { secret?: string };
|
|
85
|
+
|
|
86
|
+
const candidate = submitted ?? null;
|
|
87
|
+
const write = authorizeCapability(candidate, "console:write");
|
|
88
|
+
const grant = write ?? authorizeCapability(candidate, "console:read");
|
|
89
|
+
if (!grant) throw new Error("Invalid admin secret");
|
|
90
|
+
const scope: ConsoleScope = write ? "write" : "read";
|
|
91
|
+
|
|
92
|
+
const token = await tokenService.createToken(
|
|
93
|
+
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role, claims: { scope } },
|
|
94
|
+
{ audience: CONSOLE_AUDIENCE, expiresIn },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
req.cookies.set(CONSOLE_SESSION_COOKIE, token, {
|
|
98
|
+
...cookieOptions,
|
|
99
|
+
maxAge: parseDurationToSeconds(expiresIn),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
log.info({ scope }, "console session issued");
|
|
103
|
+
|
|
104
|
+
return { expiresIn: parseDurationToSeconds(expiresIn), scope, superset: grant.superset };
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
authorize: async (req) => {
|
|
108
|
+
const payload = await readSession(req);
|
|
109
|
+
if (!payload) return null;
|
|
110
|
+
|
|
111
|
+
prune();
|
|
112
|
+
return revoked.has(payload.jti) ? null : payload.scope;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
logout: async (req) => {
|
|
116
|
+
const payload = await readSession(req);
|
|
117
|
+
if (payload) {
|
|
118
|
+
// The token stays valid until it expires, so the revocation record has
|
|
119
|
+
// to outlive it: one full session lifetime is the upper bound.
|
|
120
|
+
revoked.set(payload.jti, Date.now() + sessionMs);
|
|
121
|
+
log.info("console session revoked");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
|
|
125
|
+
|
|
126
|
+
return payload !== null;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
package/src/databases/common.ts
CHANGED
|
@@ -826,37 +826,82 @@ const toPositionalPlaceholder = (
|
|
|
826
826
|
return null;
|
|
827
827
|
};
|
|
828
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Page bounds applied to a list field. `null` at a call site means the query is
|
|
831
|
+
* operator-authored and exempt; `0` on either field opts that half out.
|
|
832
|
+
*/
|
|
833
|
+
export type PageLimits = {
|
|
834
|
+
defaultPageSize: number;
|
|
835
|
+
maxPageSize: number;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// Resolve the requested limit to a number, following a $ref into the runtime
|
|
839
|
+
// values. Returns undefined when there is no limit or it cannot be resolved.
|
|
840
|
+
const resolveLimitValue = (
|
|
841
|
+
value: unknown,
|
|
842
|
+
variables: Record<string, unknown>,
|
|
843
|
+
): number | undefined => {
|
|
844
|
+
const resolved = isString(value) && value.startsWith("$") ? variables[value.substring(1)] : value;
|
|
845
|
+
|
|
846
|
+
return typeof resolved === "number" ? resolved : undefined;
|
|
847
|
+
};
|
|
848
|
+
|
|
829
849
|
// Factory function for pagination clause builders
|
|
830
850
|
export const buildPaginationClauseFp =
|
|
831
851
|
(dbType: DatabaseType = "pg") =>
|
|
832
|
-
(
|
|
852
|
+
(
|
|
853
|
+
field: SelectionAnalysis,
|
|
854
|
+
variablesDefinition: VariableDefinition[] = [],
|
|
855
|
+
variables: Record<string, unknown> = {},
|
|
856
|
+
pageLimits: PageLimits | null = null,
|
|
857
|
+
): string => {
|
|
833
858
|
const args = field.arguments;
|
|
834
859
|
|
|
835
|
-
|
|
836
|
-
|
|
860
|
+
const requested = resolveLimitValue(args?.["limit"], variables);
|
|
861
|
+
|
|
862
|
+
if (pageLimits && pageLimits.maxPageSize > 0 && requested !== undefined) {
|
|
863
|
+
if (requested > pageLimits.maxPageSize) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`Requested page size of ${requested} exceeds the maximum allowed page size of ${pageLimits.maxPageSize} (field: "${field.alias || field.name}"). Raise MAX_PAGE_SIZE to allow larger pages.`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
837
868
|
}
|
|
838
869
|
|
|
839
870
|
// Convert limit variable reference to positional placeholder
|
|
840
|
-
const limitPlaceholder =
|
|
841
|
-
|
|
842
|
-
|
|
871
|
+
const limitPlaceholder =
|
|
872
|
+
args?.["limit"] === undefined
|
|
873
|
+
? null
|
|
874
|
+
: toPositionalPlaceholder(args["limit"], variablesDefinition, dbType);
|
|
875
|
+
|
|
876
|
+
// An unbounded list field reads the whole table. Supply the default page
|
|
877
|
+
// size as a literal: minting a variable here would have to push onto the
|
|
878
|
+
// same definition list the driver binds positionally from, and that order
|
|
879
|
+
// is load-bearing. The value is an env-validated integer, never caller text.
|
|
880
|
+
const limitClause =
|
|
881
|
+
limitPlaceholder ??
|
|
882
|
+
(field.isArray && pageLimits && pageLimits.defaultPageSize > 0
|
|
883
|
+
? String(pageLimits.defaultPageSize)
|
|
884
|
+
: null);
|
|
885
|
+
|
|
886
|
+
if (!limitClause) {
|
|
887
|
+
return "";
|
|
843
888
|
}
|
|
844
889
|
|
|
845
890
|
// Convert offset variable reference to positional placeholder (default to 0 if not provided)
|
|
846
|
-
const offsetPlaceholder = args["offset"]
|
|
891
|
+
const offsetPlaceholder = args?.["offset"]
|
|
847
892
|
? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
|
|
848
893
|
: "0";
|
|
849
894
|
|
|
850
895
|
if (dbType !== "mssql") {
|
|
851
|
-
return `LIMIT ${
|
|
896
|
+
return `LIMIT ${limitClause} OFFSET ${offsetPlaceholder}`;
|
|
852
897
|
}
|
|
853
898
|
|
|
854
899
|
// T-SQL only accepts OFFSET/FETCH after an ORDER BY. Dropping the limit when
|
|
855
900
|
// the query has none returned the whole table instead, so supply the
|
|
856
901
|
// order-agnostic ORDER BY that T-SQL accepts for exactly this case.
|
|
857
|
-
const orderByFallback = args["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
|
|
902
|
+
const orderByFallback = args?.["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
|
|
858
903
|
|
|
859
|
-
return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${
|
|
904
|
+
return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitClause} ROWS ONLY`;
|
|
860
905
|
};
|
|
861
906
|
|
|
862
907
|
export const buildPaginationClausePG = buildPaginationClauseFp("pg");
|
|
@@ -14,6 +14,8 @@ export const executeQuery = async <T>(
|
|
|
14
14
|
db: Database,
|
|
15
15
|
variablesDefinition: VariableDefinition[],
|
|
16
16
|
variables: Record<string, unknown>,
|
|
17
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
18
|
+
timeoutMs?: number,
|
|
17
19
|
) => {
|
|
18
20
|
const adapter = databaseAdapters[db?.type];
|
|
19
21
|
if (!adapter) {
|
|
@@ -24,7 +26,7 @@ export const executeQuery = async <T>(
|
|
|
24
26
|
const startTime = Bun.nanoseconds();
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
|
-
const result = await adapter.execute<T>(query, db, variablesDefinition, variables);
|
|
29
|
+
const result = await adapter.execute<T>(query, db, variablesDefinition, variables, timeoutMs);
|
|
28
30
|
log.debug(
|
|
29
31
|
{ durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
|
|
30
32
|
"query executed",
|
|
@@ -41,6 +43,8 @@ export const executeQueryJSON = async <T>(
|
|
|
41
43
|
db: Database,
|
|
42
44
|
variablesDefinition: VariableDefinition[],
|
|
43
45
|
variables: Record<string, unknown>,
|
|
46
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
47
|
+
timeoutMs?: number,
|
|
44
48
|
) => {
|
|
45
49
|
const adapter = databaseAdapters[db.type];
|
|
46
50
|
if (!adapter) {
|
|
@@ -51,7 +55,13 @@ export const executeQueryJSON = async <T>(
|
|
|
51
55
|
const startTime = Bun.nanoseconds();
|
|
52
56
|
|
|
53
57
|
try {
|
|
54
|
-
const result = await adapter.executeJson<T>(
|
|
58
|
+
const result = await adapter.executeJson<T>(
|
|
59
|
+
query,
|
|
60
|
+
db,
|
|
61
|
+
variablesDefinition,
|
|
62
|
+
variables,
|
|
63
|
+
timeoutMs,
|
|
64
|
+
);
|
|
55
65
|
log.debug(
|
|
56
66
|
{ durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
|
|
57
67
|
"query executed (json)",
|
|
@@ -68,7 +78,6 @@ export const executeQueryJSON = async <T>(
|
|
|
68
78
|
|
|
69
79
|
export const callStoredProcedure = async (
|
|
70
80
|
sp: ProcedureResolver,
|
|
71
|
-
variablesDefinition: VariableDefinition[],
|
|
72
81
|
variables: Record<string, unknown> = {},
|
|
73
82
|
) => {
|
|
74
83
|
const adapter = databaseAdapters[sp.db!.type];
|
|
@@ -84,7 +93,7 @@ export const callStoredProcedure = async (
|
|
|
84
93
|
const startTime = Bun.nanoseconds();
|
|
85
94
|
|
|
86
95
|
try {
|
|
87
|
-
const result = await adapter.callStoredProcedure(sp,
|
|
96
|
+
const result = await adapter.callStoredProcedure(sp, variables);
|
|
88
97
|
log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
|
|
89
98
|
return result;
|
|
90
99
|
} catch (error) {
|
|
@@ -3,6 +3,7 @@ import type { MergedEntities } from "../../configuration/getSchemas/mergeEntitie
|
|
|
3
3
|
import type { Auth, Database, DatabaseType } from "../../types/configuration";
|
|
4
4
|
import type { DatabaseStructure, ProcedureResolver, TableResolver, View } from "../../types/db";
|
|
5
5
|
import type { GenerateCreateTableSQLParameters } from "../schemaBuilder/generateCreateTable";
|
|
6
|
+
import type { PageLimits } from "../common";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
checkUserCredentials as checkUserCredentialsMSSQL,
|
|
@@ -85,22 +86,25 @@ type DatabaseFunctions = {
|
|
|
85
86
|
operation: OperationAnalysis,
|
|
86
87
|
variables: Record<string, unknown>,
|
|
87
88
|
forHashMethod: boolean,
|
|
89
|
+
pageLimits: PageLimits | null,
|
|
90
|
+
timeoutMs: number | undefined,
|
|
88
91
|
) => string;
|
|
89
92
|
execute: <T>(
|
|
90
93
|
query: string,
|
|
91
94
|
db: Database,
|
|
92
95
|
variablesDefinition: VariableDefinition[],
|
|
93
96
|
values: Record<string, unknown>,
|
|
97
|
+
timeoutMs: number | undefined,
|
|
94
98
|
) => Promise<T[]>;
|
|
95
99
|
executeJson: <T>(
|
|
96
100
|
query: string,
|
|
97
101
|
db: Database,
|
|
98
102
|
variablesDefinition: VariableDefinition[],
|
|
99
103
|
values: Record<string, unknown>,
|
|
104
|
+
timeoutMs: number | undefined,
|
|
100
105
|
) => Promise<T>;
|
|
101
106
|
callStoredProcedure: (
|
|
102
107
|
sp: ProcedureResolver,
|
|
103
|
-
variablesDefinition: VariableDefinition[],
|
|
104
108
|
parameters: Record<string, unknown>,
|
|
105
109
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
106
110
|
) => Promise<any>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { VariableDefinition } from "../../analyzeQuery/types";
|
|
2
|
+
import type { ProcedureResolver } from "../../types/db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Orders a procedure call's arguments for the engines that bind positionally.
|
|
6
|
+
*
|
|
7
|
+
* `$1, $2, …` are numbered by one list and filled from another, so the two have
|
|
8
|
+
* to be the same list or the values land on the wrong parameters. Signature
|
|
9
|
+
* order comes from the introspected metadata; the caller's argument object is
|
|
10
|
+
* unordered and reflects the GraphQL query text.
|
|
11
|
+
*
|
|
12
|
+
* Arguments the metadata doesn't account for keep their supplied order and
|
|
13
|
+
* follow: an unnamed procedure parameter is introspected under a synthesized
|
|
14
|
+
* name that no caller can match, and dropping the value there would be worse
|
|
15
|
+
* than binding it where it was already going.
|
|
16
|
+
*/
|
|
17
|
+
export const orderProcedureArguments = (
|
|
18
|
+
sp: ProcedureResolver,
|
|
19
|
+
variables: Record<string, unknown>,
|
|
20
|
+
): VariableDefinition[] => {
|
|
21
|
+
const declared = sp.parameters.map((p) => p.name).filter((name) => name in variables);
|
|
22
|
+
const undeclared = Object.keys(variables).filter((name) => !declared.includes(name));
|
|
23
|
+
|
|
24
|
+
return [...declared, ...undeclared].map((name) => ({ name, type: "", required: false }));
|
|
25
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnalysisResult, SelectionAnalysis } from "../../analyzeQuery/types";
|
|
2
2
|
import type { MergedEntities } from "../../configuration/getSchemas/mergeEntities";
|
|
3
3
|
import type { Database } from "../../types/configuration";
|
|
4
|
+
import type { PageLimits } from "../common";
|
|
4
5
|
|
|
5
6
|
import { databaseAdapters } from "./function-mapping";
|
|
6
7
|
|
|
@@ -9,6 +10,10 @@ export const generateSQL = (
|
|
|
9
10
|
analysisResult: AnalysisResult,
|
|
10
11
|
variables: Record<string, unknown> = {},
|
|
11
12
|
forHashMethod: boolean = false,
|
|
13
|
+
/** `null` exempts the query — operator-authored queries are configuration, not caller input. */
|
|
14
|
+
pageLimits: PageLimits | null = null,
|
|
15
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
16
|
+
timeoutMs?: number,
|
|
12
17
|
) => {
|
|
13
18
|
const operation = analysisResult.operations[0];
|
|
14
19
|
|
|
@@ -40,6 +45,8 @@ export const generateSQL = (
|
|
|
40
45
|
},
|
|
41
46
|
variables,
|
|
42
47
|
forHashMethod,
|
|
48
|
+
pageLimits,
|
|
49
|
+
timeoutMs,
|
|
43
50
|
);
|
|
44
51
|
|
|
45
52
|
return [db, query] as const;
|