@graphoria/server 0.3.0 → 0.5.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 +28 -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/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- 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/authentication/tokenRepository.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +25 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/index.d.ts +40 -2
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -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.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +30 -1
- 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/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +12 -4
- package/dist/src/console/session.d.ts.map +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/databases/common.d.ts +20 -6
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +4 -6
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +2 -3
- 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 +5 -0
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +5 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/metadata/structure.d.ts +5 -0
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts +4 -0
- 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/logging/slowQuery.d.ts +29 -0
- package/dist/src/logging/slowQuery.d.ts.map +1 -0
- package/dist/src/logging/slowQuery.test.d.ts +2 -0
- package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
- package/dist/src/observability/health.d.ts +23 -0
- package/dist/src/observability/health.d.ts.map +1 -0
- package/dist/src/observability/health.test.d.ts +2 -0
- package/dist/src/observability/health.test.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.d.ts +7 -0
- package/dist/src/observability/httpMetrics.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.test.d.ts +2 -0
- package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.d.ts +11 -0
- package/dist/src/observability/httpTracing.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.test.d.ts +2 -0
- package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.d.ts +81 -0
- package/dist/src/observability/metrics.d.ts.map +1 -0
- package/dist/src/observability/metrics.perf.test.d.ts +2 -0
- package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.test.d.ts +2 -0
- package/dist/src/observability/metrics.test.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.d.ts +19 -0
- package/dist/src/observability/metricsRoute.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.test.d.ts +2 -0
- package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.d.ts +92 -0
- package/dist/src/observability/tracing.d.ts.map +1 -0
- package/dist/src/observability/tracing.perf.test.d.ts +2 -0
- package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.test.d.ts +2 -0
- package/dist/src/observability/tracing.test.d.ts.map +1 -0
- package/dist/src/queues/kafka.d.ts +6 -0
- package/dist/src/queues/kafka.d.ts.map +1 -1
- package/dist/src/queues/kafka.test.d.ts +2 -0
- package/dist/src/queues/kafka.test.d.ts.map +1 -0
- package/dist/src/queues/rabbitmq.d.ts.map +1 -1
- package/dist/src/remoteREST/proxy.d.ts.map +1 -1
- package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
- package/dist/src/singletons/cache/redisClient.d.ts +2 -2
- package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
- package/dist/src/singletons/databases.d.ts +1 -0
- package/dist/src/singletons/databases.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +37 -4
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queues.d.ts +1 -0
- package/dist/src/singletons/queues.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts +1 -0
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
- package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
- package/dist/src/types/env.d.ts +113 -12
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +25 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +85 -0
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts.map +1 -1
- package/dist/src/utils/redis.d.ts +17 -0
- package/dist/src/utils/redis.d.ts.map +1 -0
- package/dist/src/utils/redis.test.d.ts +2 -0
- package/dist/src/utils/redis.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 +8 -5
- package/playgrounds/console/index.html +9 -9
- package/playgrounds/graphiql/index.html +549 -686
- package/playgrounds/scalar/index.html +1 -1
- package/src/ai/mcp/index.ts +19 -14
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
- package/src/analyzeQuery/depthLimit.ts +15 -5
- package/src/authentication/capabilities.ts +56 -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/authentication/tokenRepository.ts +2 -3
- package/src/config/types/db.ts +16 -2
- package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
- package/src/configuration/gql/gqlAuthOperations.ts +26 -0
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +241 -148
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
- package/src/configuration/rest/generateOpenAPI.ts +61 -11
- package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
- package/src/console/api.ts +64 -24
- package/src/console/session.ts +30 -26
- package/src/cron/index.ts +59 -36
- package/src/databases/common.ts +109 -10
- package/src/databases/core/executor.ts +97 -20
- package/src/databases/core/function-mapping.ts +1 -2
- package/src/databases/core/procedure-arguments.ts +25 -0
- package/src/databases/engines/mssql/connection.ts +0 -3
- package/src/databases/engines/mssql/query/index.ts +4 -1
- package/src/databases/engines/mysql/connection.ts +10 -7
- package/src/databases/engines/mysql/query/index.ts +5 -2
- package/src/databases/engines/postgresql/connection.ts +10 -7
- package/src/databases/engines/postgresql/query/index.ts +5 -2
- package/src/databases/high-level-operations.ts +44 -2
- package/src/index.ts +200 -76
- package/src/logging/audit.ts +86 -0
- package/src/logging/slowQuery.ts +59 -0
- package/src/observability/health.ts +67 -0
- package/src/observability/httpMetrics.ts +37 -0
- package/src/observability/httpTracing.ts +48 -0
- package/src/observability/metrics.ts +243 -0
- package/src/observability/metricsRoute.ts +33 -0
- package/src/observability/tracing.ts +421 -0
- package/src/queues/kafka.ts +45 -2
- package/src/queues/rabbitmq.ts +37 -1
- package/src/remoteREST/proxy.ts +17 -0
- package/src/remoteSchemas/proxy.ts +17 -0
- package/src/singletons/cache/redisClient.ts +4 -5
- package/src/singletons/databases.ts +5 -0
- package/src/singletons/queues.ts +9 -3
- package/src/subscriptions/strategies/database.ts +2 -0
- package/src/subscriptions/utils/polling.ts +80 -21
- package/src/types/env.ts +53 -8
- package/src/utils/rateLimit.ts +6 -1
- package/src/utils/redis.ts +71 -0
- package/src/utils/responses.ts +12 -0
- package/src/utils/sessionVariables.ts +2 -0
|
@@ -12,6 +12,7 @@ import type { HandleGraphQLRequest } from "../gql/handleGraphQLRequestFactory";
|
|
|
12
12
|
import { analyzeQuery } from "../../analyzeQuery";
|
|
13
13
|
import { checkUserCredentials } from "../../databases";
|
|
14
14
|
import { proxyRemoteRESTRequest } from "../../remoteREST/proxy";
|
|
15
|
+
import { actorFromSession, audit } from "../../logging/audit";
|
|
15
16
|
import { getTokenService } from "../../singletons/authentication";
|
|
16
17
|
import { getCache } from "../../singletons/cache";
|
|
17
18
|
import { databasesConnections, repositoryMap } from "../../singletons/databases";
|
|
@@ -174,9 +175,23 @@ export const handleRESTRequestFactory = (
|
|
|
174
175
|
|
|
175
176
|
const result = await checkUserCredentials(auth, username, password);
|
|
176
177
|
|
|
178
|
+
const actor = { type: "credentials", sub: username } as const;
|
|
177
179
|
if (!result.valid) {
|
|
180
|
+
audit().emit({
|
|
181
|
+
action: "auth.login",
|
|
182
|
+
outcome: "failure",
|
|
183
|
+
actor,
|
|
184
|
+
target: { kind: "auth", via: "rest" },
|
|
185
|
+
reason: "Invalid username or password",
|
|
186
|
+
});
|
|
178
187
|
return new S401({ errors: ["Invalid username or password"] });
|
|
179
188
|
}
|
|
189
|
+
audit().emit({
|
|
190
|
+
action: "auth.login",
|
|
191
|
+
outcome: "success",
|
|
192
|
+
actor,
|
|
193
|
+
target: { kind: "auth", via: "rest", role: result.role },
|
|
194
|
+
});
|
|
180
195
|
|
|
181
196
|
const data = await getTokenService().createTokenPair({
|
|
182
197
|
sub: username,
|
|
@@ -229,9 +244,11 @@ export const handleRESTRequestFactory = (
|
|
|
229
244
|
|
|
230
245
|
if (route.authOperation === "logout") {
|
|
231
246
|
const tokenService = getTokenService();
|
|
247
|
+
const revoked: string[] = [];
|
|
232
248
|
|
|
233
249
|
if (session?.jti) {
|
|
234
250
|
await tokenService.revoke(session.jti);
|
|
251
|
+
revoked.push(session.jti);
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
const refreshCookie = req?.cookies?.get("refresh_token");
|
|
@@ -241,6 +258,7 @@ export const handleRESTRequestFactory = (
|
|
|
241
258
|
audience: "refresh",
|
|
242
259
|
});
|
|
243
260
|
await tokenService.revoke(refreshPayload.jti);
|
|
261
|
+
revoked.push(refreshPayload.jti);
|
|
244
262
|
} catch {
|
|
245
263
|
// tampered or expired cookie — nothing to revoke
|
|
246
264
|
}
|
|
@@ -250,6 +268,14 @@ export const handleRESTRequestFactory = (
|
|
|
250
268
|
req.cookies.delete("refresh_token");
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
if (revoked.length > 0) {
|
|
272
|
+
audit().emit({
|
|
273
|
+
action: "auth.logout",
|
|
274
|
+
actor: actorFromSession(session),
|
|
275
|
+
target: { kind: "auth", via: "rest", revoked },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
253
279
|
return new S200({ data: true });
|
|
254
280
|
}
|
|
255
281
|
|
package/src/console/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConsoleScope } from "./session";
|
|
1
2
|
import type { TokenService } from "../authentication/types";
|
|
2
3
|
import type { AnalyzedConfiguration } from "../configuration";
|
|
3
4
|
import type { Configuration } from "../types/configuration";
|
|
@@ -8,11 +9,12 @@ import type { ConnectionPool } from "mssql";
|
|
|
8
9
|
|
|
9
10
|
import { createConsoleSessions } from "./session";
|
|
10
11
|
import { getTags } from "../configuration/rest/generateOpenAPI";
|
|
12
|
+
import { audit } from "../logging/audit";
|
|
11
13
|
import { getCronJobs } from "../singletons/cron";
|
|
12
|
-
import { databasesConnections } from "../singletons/databases";
|
|
14
|
+
import { databasesConnections, pingConnection } from "../singletons/databases";
|
|
13
15
|
import { queueManager } from "../singletons/queues";
|
|
14
16
|
import { resolveClientAddress } from "../utils/rateLimit";
|
|
15
|
-
import { S200, S400, S401, S404, S429 } from "../utils/responses";
|
|
17
|
+
import { S200, S400, S401, S403, S404, S429 } from "../utils/responses";
|
|
16
18
|
|
|
17
19
|
type ConsoleRoutesFactoryOptions = {
|
|
18
20
|
env: Env;
|
|
@@ -33,11 +35,6 @@ type ConsoleRouteHandler = (
|
|
|
33
35
|
|
|
34
36
|
const PING_TIMEOUT_MS = 2000;
|
|
35
37
|
|
|
36
|
-
const pingConnection = (connection: SQL | ConnectionPool, type: string) =>
|
|
37
|
-
type === "mssql"
|
|
38
|
-
? (connection as ConnectionPool).query("SELECT 1")
|
|
39
|
-
: (connection as SQL).unsafe("SELECT 1");
|
|
40
|
-
|
|
41
38
|
const measureLatency = async (connection: SQL | ConnectionPool, type: string) => {
|
|
42
39
|
const start = performance.now();
|
|
43
40
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -67,12 +64,25 @@ export const consoleRoutesFactory = ({
|
|
|
67
64
|
}: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
|
|
68
65
|
const sessions = createConsoleSessions({ env, consolePath, tokenService });
|
|
69
66
|
|
|
67
|
+
const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
|
|
68
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy);
|
|
69
|
+
|
|
70
70
|
const guarded =
|
|
71
|
-
(
|
|
72
|
-
|
|
71
|
+
(
|
|
72
|
+
handler: (
|
|
73
|
+
req: BunRequest,
|
|
74
|
+
server: Bun.Server<unknown> | undefined,
|
|
75
|
+
scope: ConsoleScope,
|
|
76
|
+
) => object | Promise<object>,
|
|
77
|
+
required: ConsoleScope = "read",
|
|
78
|
+
): ConsoleRouteHandler =>
|
|
79
|
+
async (req: BunRequest, server?: Bun.Server<unknown>) => {
|
|
73
80
|
try {
|
|
74
|
-
|
|
75
|
-
return new
|
|
81
|
+
const scope = await sessions.authorize(req);
|
|
82
|
+
if (!scope) return new S404({ error: "Not Found" });
|
|
83
|
+
if (required === "write" && scope !== "write")
|
|
84
|
+
return new S403({ errors: [{ message: "Console session is read-only" }] });
|
|
85
|
+
return new S200(await handler(req, server, scope));
|
|
76
86
|
} catch (error) {
|
|
77
87
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
78
88
|
}
|
|
@@ -99,15 +109,28 @@ export const consoleRoutesFactory = ({
|
|
|
99
109
|
// Keyed by address against the anonymous ceiling: there is no session
|
|
100
110
|
// yet, and the attempt is charged before the secret is compared, so a
|
|
101
111
|
// guesser cannot spend attempts for free.
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
105
|
-
);
|
|
112
|
+
const ip = clientAddress(req, server);
|
|
113
|
+
const limit = await rateLimiter?.check(null, ip);
|
|
106
114
|
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
107
115
|
|
|
116
|
+
const actor = { type: "admin_secret", ip } as const;
|
|
108
117
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
const { expiresIn, scope, superset } = await sessions.login(req);
|
|
119
|
+
audit().emit({
|
|
120
|
+
action: "console.login",
|
|
121
|
+
outcome: "success",
|
|
122
|
+
actor: { ...actor, scope: superset ? "all" : `console:${scope}` },
|
|
123
|
+
target: { kind: "console" },
|
|
124
|
+
});
|
|
125
|
+
return new S200({ expiresIn, scope });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
audit().emit({
|
|
128
|
+
action: "console.login",
|
|
129
|
+
outcome: "failure",
|
|
130
|
+
actor,
|
|
131
|
+
target: { kind: "console" },
|
|
132
|
+
reason: (error as Error)?.message,
|
|
133
|
+
});
|
|
111
134
|
// One message for a bad secret and for a malformed body alike: the
|
|
112
135
|
// caller learns nothing about which it was.
|
|
113
136
|
return new S401({ errors: [{ message: "Invalid admin secret" }] });
|
|
@@ -117,8 +140,14 @@ export const consoleRoutesFactory = ({
|
|
|
117
140
|
|
|
118
141
|
[`${base}/logout`]: {
|
|
119
142
|
...cors,
|
|
120
|
-
POST: async (req) => {
|
|
121
|
-
await sessions.logout(req)
|
|
143
|
+
POST: async (req, server) => {
|
|
144
|
+
if (await sessions.logout(req)) {
|
|
145
|
+
audit().emit({
|
|
146
|
+
action: "console.logout",
|
|
147
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
148
|
+
target: { kind: "console" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
122
151
|
return new S200({ ok: true });
|
|
123
152
|
},
|
|
124
153
|
},
|
|
@@ -264,7 +293,7 @@ export const consoleRoutesFactory = ({
|
|
|
264
293
|
|
|
265
294
|
[`${base}/config`]: {
|
|
266
295
|
...cors,
|
|
267
|
-
GET: guarded(() => ({
|
|
296
|
+
GET: guarded((_req, _server, scope) => ({
|
|
268
297
|
name: projectConfiguration.name,
|
|
269
298
|
version: projectConfiguration.version,
|
|
270
299
|
prefixes,
|
|
@@ -274,12 +303,13 @@ export const consoleRoutesFactory = ({
|
|
|
274
303
|
mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
|
|
275
304
|
cors: env.enableCors,
|
|
276
305
|
},
|
|
306
|
+
session: { scope },
|
|
277
307
|
})),
|
|
278
308
|
},
|
|
279
309
|
|
|
280
310
|
[`${base}/queues/publish`]: {
|
|
281
311
|
...cors,
|
|
282
|
-
POST: guarded(async (req) => {
|
|
312
|
+
POST: guarded(async (req, server) => {
|
|
283
313
|
const { publisher, message, key } = (await req.json()) as {
|
|
284
314
|
publisher?: string;
|
|
285
315
|
message?: string | object;
|
|
@@ -288,13 +318,18 @@ export const consoleRoutesFactory = ({
|
|
|
288
318
|
if (!publisher || message == null) throw new Error("publisher and message are required");
|
|
289
319
|
if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
|
|
290
320
|
throw new Error(`Unknown publisher "${publisher}"`);
|
|
321
|
+
audit().emit({
|
|
322
|
+
action: "console.queue.publish",
|
|
323
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
324
|
+
target: { kind: "publisher", name: publisher, key },
|
|
325
|
+
});
|
|
291
326
|
return { ok: await queueManager!.sendMessage(publisher, message, key) };
|
|
292
|
-
}),
|
|
327
|
+
}, "write"),
|
|
293
328
|
},
|
|
294
329
|
|
|
295
330
|
[`${base}/cron`]: {
|
|
296
331
|
...cors,
|
|
297
|
-
POST: guarded(async (req) => {
|
|
332
|
+
POST: guarded(async (req, server) => {
|
|
298
333
|
const { name, action } = (await req.json()) as { name?: string; action?: string };
|
|
299
334
|
const cron = getCronJobs();
|
|
300
335
|
if (!cron) throw new Error("Cron is not enabled");
|
|
@@ -302,10 +337,15 @@ export const consoleRoutesFactory = ({
|
|
|
302
337
|
if (action !== "trigger" && action !== "pause" && action !== "resume")
|
|
303
338
|
throw new Error(`Unknown action "${action}"`);
|
|
304
339
|
if (!cron.getJob(name)) throw new Error(`Unknown job "${name}"`);
|
|
340
|
+
audit().emit({
|
|
341
|
+
action: `console.cron.${action}`,
|
|
342
|
+
actor: { type: "console", ip: clientAddress(req, server) },
|
|
343
|
+
target: { kind: "cron", name },
|
|
344
|
+
});
|
|
305
345
|
if (action === "trigger") await cron.trigger(name);
|
|
306
346
|
else cron[action](name);
|
|
307
347
|
return { ok: true };
|
|
308
|
-
}),
|
|
348
|
+
}, "write"),
|
|
309
349
|
},
|
|
310
350
|
};
|
|
311
351
|
};
|
package/src/console/session.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { timingSafeEqual } from "crypto";
|
|
2
|
-
|
|
3
1
|
import type { BunRequest } from "bun";
|
|
4
2
|
import type { TokenService } from "../authentication/types";
|
|
5
3
|
import type { Env } from "../types/env";
|
|
6
4
|
|
|
5
|
+
import { createCapabilityAuthorizer } from "../authentication/capabilities";
|
|
7
6
|
import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
|
|
8
7
|
import { logger } from "../logging";
|
|
9
8
|
|
|
@@ -14,23 +13,21 @@ export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
|
|
|
14
13
|
const CONSOLE_AUDIENCE = "console";
|
|
15
14
|
const CONSOLE_SUBJECT = "console";
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (bufA.length !== bufB.length) return false;
|
|
22
|
-
return timingSafeEqual(bufA, bufB);
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
};
|
|
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";
|
|
27
20
|
|
|
28
21
|
export type ConsoleSessions = {
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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>;
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
type ConsoleSessionsOptions = {
|
|
@@ -45,6 +42,7 @@ export const createConsoleSessions = ({
|
|
|
45
42
|
tokenService,
|
|
46
43
|
}: ConsoleSessionsOptions): ConsoleSessions => {
|
|
47
44
|
const log = logger("console");
|
|
45
|
+
const authorizeCapability = createCapabilityAuthorizer(env);
|
|
48
46
|
const expiresIn = env.console.sessionExpiresIn;
|
|
49
47
|
const sessionMs = parseDurationToMs(expiresIn);
|
|
50
48
|
|
|
@@ -73,7 +71,9 @@ export const createConsoleSessions = ({
|
|
|
73
71
|
try {
|
|
74
72
|
const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
|
|
75
73
|
if (payload.role !== env.superadmin.role) return null;
|
|
76
|
-
|
|
74
|
+
const scope = payload.claims?.scope;
|
|
75
|
+
if (!isScope(scope)) return null;
|
|
76
|
+
return { ...payload, scope };
|
|
77
77
|
} catch {
|
|
78
78
|
return null;
|
|
79
79
|
}
|
|
@@ -83,12 +83,14 @@ export const createConsoleSessions = ({
|
|
|
83
83
|
login: async (req) => {
|
|
84
84
|
const { secret: submitted } = (await req.json()) as { secret?: string };
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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";
|
|
89
91
|
|
|
90
92
|
const token = await tokenService.createToken(
|
|
91
|
-
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role },
|
|
93
|
+
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role, claims: { scope } },
|
|
92
94
|
{ audience: CONSOLE_AUDIENCE, expiresIn },
|
|
93
95
|
);
|
|
94
96
|
|
|
@@ -97,17 +99,17 @@ export const createConsoleSessions = ({
|
|
|
97
99
|
maxAge: parseDurationToSeconds(expiresIn),
|
|
98
100
|
});
|
|
99
101
|
|
|
100
|
-
log.info("console session issued");
|
|
102
|
+
log.info({ scope }, "console session issued");
|
|
101
103
|
|
|
102
|
-
return { expiresIn: parseDurationToSeconds(expiresIn) };
|
|
104
|
+
return { expiresIn: parseDurationToSeconds(expiresIn), scope, superset: grant.superset };
|
|
103
105
|
},
|
|
104
106
|
|
|
105
107
|
authorize: async (req) => {
|
|
106
108
|
const payload = await readSession(req);
|
|
107
|
-
if (!payload) return
|
|
109
|
+
if (!payload) return null;
|
|
108
110
|
|
|
109
111
|
prune();
|
|
110
|
-
return
|
|
112
|
+
return revoked.has(payload.jti) ? null : payload.scope;
|
|
111
113
|
},
|
|
112
114
|
|
|
113
115
|
logout: async (req) => {
|
|
@@ -120,6 +122,8 @@ export const createConsoleSessions = ({
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
|
|
125
|
+
|
|
126
|
+
return payload !== null;
|
|
123
127
|
},
|
|
124
128
|
};
|
|
125
129
|
};
|
package/src/cron/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { TickContext } from "../types/zod/cron";
|
|
|
7
7
|
import { databasesConnections, repositoryMap } from "../singletons/databases";
|
|
8
8
|
import { queueManager } from "../singletons/queues";
|
|
9
9
|
import { logger } from "../logging";
|
|
10
|
+
import { incMetric, observeMetric } from "../observability/metrics";
|
|
11
|
+
import { startSpan, withActiveSpan } from "../observability/tracing";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Interface for a scheduled cron job instance
|
|
@@ -58,42 +60,63 @@ const createScheduledJob = (config: CronJob, gqlQuery: GqlQueryFn<true>): Schedu
|
|
|
58
60
|
context: config.context,
|
|
59
61
|
},
|
|
60
62
|
async (self) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
63
|
+
const startTime = Bun.nanoseconds();
|
|
64
|
+
let outcome: "success" | "error" = "success";
|
|
65
|
+
// A tick is not inside a request, so it roots its own trace. The span is
|
|
66
|
+
// entered so anything `onTick` runs through `gqlQuery` lands under it.
|
|
67
|
+
const span = startSpan("cron.tick", {
|
|
68
|
+
parent: null,
|
|
69
|
+
attributes: { "graphoria.cron.job": config.name },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return withActiveSpan(span, async () => {
|
|
73
|
+
try {
|
|
74
|
+
log.info("executing");
|
|
75
|
+
|
|
76
|
+
executionCount++;
|
|
77
|
+
|
|
78
|
+
// Execute query if provided
|
|
79
|
+
const response = config.query
|
|
80
|
+
? await gqlQuery(config.query, config.variables ?? {})
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
const tickContext: TickContext = {
|
|
84
|
+
name: config.name,
|
|
85
|
+
pattern: config.pattern,
|
|
86
|
+
variables: config.variables || {},
|
|
87
|
+
executionCount,
|
|
88
|
+
nextRun: self.nextRun(),
|
|
89
|
+
previousRun: self.previousRun(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Call the onTick callback if provided, passing options object
|
|
93
|
+
await config.onTick?.(
|
|
94
|
+
{
|
|
95
|
+
gqlQuery,
|
|
96
|
+
databases: databasesConnections,
|
|
97
|
+
queues: queueManager,
|
|
98
|
+
repository: repositoryMap,
|
|
99
|
+
},
|
|
100
|
+
tickContext,
|
|
101
|
+
response,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
log.info({ executionCount, nextRun: self.nextRun() }, "completed");
|
|
105
|
+
} catch (error) {
|
|
106
|
+
outcome = "error";
|
|
107
|
+
log.error({ err: error }, "failed");
|
|
108
|
+
span?.recordError(error);
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
incMetric("graphoria_cron_runs_total", { job: config.name, outcome });
|
|
112
|
+
observeMetric(
|
|
113
|
+
"graphoria_cron_run_duration_seconds",
|
|
114
|
+
{ job: config.name },
|
|
115
|
+
(Bun.nanoseconds() - startTime) / 1e9,
|
|
116
|
+
);
|
|
117
|
+
span?.end();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
97
120
|
},
|
|
98
121
|
);
|
|
99
122
|
|