@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
|
@@ -48,6 +48,44 @@ export const getDatabasesStructure = async (
|
|
|
48
48
|
// do-not-serve list, so a name spelled in the wrong case served the table.
|
|
49
49
|
const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
|
|
50
50
|
|
|
51
|
+
// Normalising the ancestor to introspected casing here is what lets the query
|
|
52
|
+
// builder match it with a plain === against the entity it resolves at runtime.
|
|
53
|
+
// Resolution is per-database by construction, so a cross-database ancestor
|
|
54
|
+
// cannot resolve — which is correct, nothing joins across connections.
|
|
55
|
+
const resolveAncestor = (ancestor: { schema: string; name: string; column: string }) => {
|
|
56
|
+
const ancestorTable = tables.find(
|
|
57
|
+
(a) =>
|
|
58
|
+
`${ancestor.schema}_${ancestor.name}`.toLowerCase() ===
|
|
59
|
+
`${a.schema}_${a.name}`.toLowerCase(),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!ancestorTable) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Ancestor table ${ancestor.schema}_${ancestor.name} not found in database ${db.name}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An excluded table can never appear in a query path, so the condition could
|
|
69
|
+
// only ever throw at request time. Fail at boot instead.
|
|
70
|
+
if (excluded.has(ancestorTable.schemaName.toLowerCase())) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Ancestor table ${ancestor.schema}_${ancestor.name} is in excludedTables and can never be in a query path`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const column = ancestorTable.columns.find(
|
|
77
|
+
(c) => c.name.toLowerCase() === ancestor.column.toLowerCase(),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (!column) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Ancestor column ${ancestor.column} not found in table ${ancestorTable.name}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { schema: ancestorTable.schema, name: ancestorTable.name, column: column.name };
|
|
87
|
+
};
|
|
88
|
+
|
|
51
89
|
tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
|
|
52
90
|
|
|
53
91
|
const tablesToAdd = tables
|
|
@@ -117,6 +155,10 @@ export const getDatabasesStructure = async (
|
|
|
117
155
|
};
|
|
118
156
|
}),
|
|
119
157
|
conditions: (fk.conditions ?? []).map((cond) => {
|
|
158
|
+
const ancestor = cond.ancestor
|
|
159
|
+
? { ancestor: resolveAncestor(cond.ancestor) }
|
|
160
|
+
: undefined;
|
|
161
|
+
|
|
120
162
|
if (cond.source !== undefined) {
|
|
121
163
|
const sourceColumn = t.columns.find(
|
|
122
164
|
(col) => col.name.toLowerCase() === cond.source!.toLowerCase(),
|
|
@@ -126,7 +168,7 @@ export const getDatabasesStructure = async (
|
|
|
126
168
|
throw new Error(`Condition column ${cond.source} not found in table ${t.name}`);
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
return { ...cond, source: sourceColumn.name };
|
|
171
|
+
return { ...cond, ...ancestor, source: sourceColumn.name };
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
const targetColumn = toTable.columns.find(
|
|
@@ -139,7 +181,7 @@ export const getDatabasesStructure = async (
|
|
|
139
181
|
);
|
|
140
182
|
}
|
|
141
183
|
|
|
142
|
-
return { ...cond, target: targetColumn.name };
|
|
184
|
+
return { ...cond, ...ancestor, target: targetColumn.name };
|
|
143
185
|
}),
|
|
144
186
|
};
|
|
145
187
|
});
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { serve } from "bun";
|
|
|
4
4
|
import { isString } from "es-toolkit";
|
|
5
5
|
|
|
6
6
|
import type { BunRequest } from "bun";
|
|
7
|
+
import type { Capability } from "./authentication/capabilities";
|
|
7
8
|
import type { Configuration } from "./types/configuration";
|
|
8
9
|
import type { Env } from "./types/env";
|
|
10
|
+
import type { SessionContext } from "./utils/sessionVariables";
|
|
9
11
|
|
|
10
12
|
import { createTokenService } from "./authentication";
|
|
11
13
|
import { analyzeConfiguration, loadConfiguration } from "./configuration";
|
|
@@ -14,14 +16,21 @@ import { websocketHandlerFactory } from "./configuration/gql/handleGraphQLSubscr
|
|
|
14
16
|
import { consoleRoutesFactory } from "./console/api";
|
|
15
17
|
import { createAuthTables, verifyAuthTablesExist } from "./databases";
|
|
16
18
|
import { createMCPRoutes } from "./ai";
|
|
19
|
+
import { createCapabilityAuthorizer } from "./authentication/capabilities";
|
|
17
20
|
import { getAgent, instantiateAI } from "./singletons/ai";
|
|
18
21
|
import { getTokenService, setTokenService } from "./singletons/authentication";
|
|
19
22
|
import { instantiateCronJobs } from "./singletons/cron";
|
|
20
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
databasesConnections,
|
|
25
|
+
disconnectDatabases,
|
|
26
|
+
instantiateDatabasesConnections,
|
|
27
|
+
pingConnection,
|
|
28
|
+
} from "./singletons/databases";
|
|
21
29
|
import { getCacheRedisClient } from "./singletons/cache/redisClient";
|
|
22
30
|
import { env } from "./singletons/env";
|
|
23
31
|
import { setQueryTimeoutMs } from "./singletons/queryTimeout";
|
|
24
|
-
import {
|
|
32
|
+
import { setSlowQueryMs } from "./logging/slowQuery";
|
|
33
|
+
import { instantiateQueues, queueManager } from "./singletons/queues";
|
|
25
34
|
import { ConfigurationZod } from "./types/zod/configuration";
|
|
26
35
|
import {
|
|
27
36
|
createMemoryRateLimitStore,
|
|
@@ -32,6 +41,13 @@ import {
|
|
|
32
41
|
import { S200, S400, S401, S404, S429 } from "./utils/responses";
|
|
33
42
|
import { writeSchema } from "./utils/writeSchema";
|
|
34
43
|
import { logger, configureLogging } from "./logging";
|
|
44
|
+
import { actorFromSession, audit } from "./logging/audit";
|
|
45
|
+
import { createHealthRoutes } from "./observability/health";
|
|
46
|
+
import { configureMetrics, renderMetrics } from "./observability/metrics";
|
|
47
|
+
import { configureTracing } from "./observability/tracing";
|
|
48
|
+
import { withHttpMetrics } from "./observability/httpMetrics";
|
|
49
|
+
import { withHttpTracing } from "./observability/httpTracing";
|
|
50
|
+
import { createMetricsRoute } from "./observability/metricsRoute";
|
|
35
51
|
|
|
36
52
|
// Re-export for consumers
|
|
37
53
|
export { configureLogging };
|
|
@@ -42,6 +58,8 @@ type RouteHandler =
|
|
|
42
58
|
|
|
43
59
|
type RoutesMap = Record<string, RouteHandler | Record<string, RouteHandler>>;
|
|
44
60
|
|
|
61
|
+
const HEALTH_CHECK_TIMEOUT_MS = 2000;
|
|
62
|
+
|
|
45
63
|
const renderPlayground = async (filepath: string, replacements: Record<string, string>) => {
|
|
46
64
|
const path = join(import.meta.dir, filepath);
|
|
47
65
|
const content = await Bun.file(path).text();
|
|
@@ -67,6 +85,8 @@ const generatePrefixes = (options: Env) => ({
|
|
|
67
85
|
rest: options.prefix + options.restApiPrefix,
|
|
68
86
|
openapi: options.prefix + options.openApiEndpoint,
|
|
69
87
|
console: options.prefix + options.console.endpoint,
|
|
88
|
+
health: options.prefix + "/health",
|
|
89
|
+
metrics: options.prefix + options.metrics.endpoint,
|
|
70
90
|
});
|
|
71
91
|
|
|
72
92
|
/**
|
|
@@ -88,6 +108,18 @@ const bootAnalyzedConfiguration = async (env: Env) => {
|
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
setQueryTimeoutMs(env.queryTimeoutMs);
|
|
111
|
+
setSlowQueryMs(env.slowQueryMs);
|
|
112
|
+
configureMetrics({
|
|
113
|
+
enabled: env.metrics.enabled,
|
|
114
|
+
maxOperationLabels: env.metrics.maxOperationLabels,
|
|
115
|
+
});
|
|
116
|
+
configureTracing({
|
|
117
|
+
enabled: env.tracing.enabled,
|
|
118
|
+
endpoint: env.tracing.endpoint,
|
|
119
|
+
headers: env.tracing.headers,
|
|
120
|
+
serviceName: env.tracing.serviceName,
|
|
121
|
+
sampleRatio: env.tracing.sampleRatio,
|
|
122
|
+
});
|
|
91
123
|
|
|
92
124
|
if (env.queryTimeoutMs === 0) {
|
|
93
125
|
logger("graphoria").warn(
|
|
@@ -244,24 +276,52 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
244
276
|
: createMemoryRateLimitStore(),
|
|
245
277
|
});
|
|
246
278
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
279
|
+
const authorizeCapability = createCapabilityAuthorizer(env);
|
|
280
|
+
|
|
281
|
+
// Helper to get role-based handlers. A route that names a capability also
|
|
282
|
+
// accepts that capability's scoped credential in the admin-secret header;
|
|
283
|
+
// it stands in for the superadmin role on that route and nowhere else.
|
|
284
|
+
const getRoleHandlers = async (
|
|
285
|
+
req: Request,
|
|
286
|
+
server?: Bun.Server<unknown>,
|
|
287
|
+
capability?: Capability,
|
|
288
|
+
) => {
|
|
289
|
+
const adminSecretHeader = req.headers.get(env.admin.header);
|
|
290
|
+
const grant = capability ? authorizeCapability(adminSecretHeader, capability) : null;
|
|
291
|
+
|
|
292
|
+
const session: SessionContext =
|
|
293
|
+
grant && !grant.superset
|
|
294
|
+
? { sub: capability, role: env.superadmin.role, authMethod: "admin_secret" }
|
|
295
|
+
: await getTokenService().verifyTokenAndGetSession(
|
|
296
|
+
req.headers.get(env.authorizationHeader),
|
|
297
|
+
adminSecretHeader,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const ip = resolveClientAddress(req, server, env.rateLimit.trustProxy);
|
|
253
301
|
|
|
254
302
|
// The limit needs the role, and the role costs a token verification plus a
|
|
255
303
|
// revocation lookup — so it is spent here, once, rather than again inside a
|
|
256
304
|
// wrapper.
|
|
257
|
-
const limit = await rateLimiter?.check(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
305
|
+
const limit = await rateLimiter?.check(session, ip);
|
|
306
|
+
|
|
307
|
+
const scope: Capability | "all" | undefined = grant
|
|
308
|
+
? grant.superset
|
|
309
|
+
? "all"
|
|
310
|
+
: capability
|
|
311
|
+
: undefined;
|
|
312
|
+
|
|
313
|
+
if (session.authMethod === "admin_secret") {
|
|
314
|
+
audit().emit({
|
|
315
|
+
action: "admin_secret.used",
|
|
316
|
+
actor: { type: "admin_secret", ...(scope ? { scope } : {}), ip },
|
|
317
|
+
target: { kind: "endpoint", method: req.method, path: new URL(req.url).pathname },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
261
320
|
|
|
262
321
|
return {
|
|
263
322
|
role: session.role!,
|
|
264
323
|
session,
|
|
324
|
+
scope,
|
|
265
325
|
limit,
|
|
266
326
|
...analyzedConfiguration.roles[session.role!].handlers,
|
|
267
327
|
};
|
|
@@ -298,6 +358,51 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
298
358
|
routes[prefixes.graphiql] = () => html(graphiqlFile);
|
|
299
359
|
routes[prefixes.scalar] = () => html(scalarFile);
|
|
300
360
|
|
|
361
|
+
// Probes for an orchestrator: no auth, no rate limit. Redis is a dependency
|
|
362
|
+
// only where something reads it — the token repository or the redis cache.
|
|
363
|
+
const redisInUse = projectConfiguration.auth?.enabled || env.cache.store === "redis";
|
|
364
|
+
Object.assign(
|
|
365
|
+
routes,
|
|
366
|
+
createHealthRoutes({
|
|
367
|
+
basePath: prefixes.health,
|
|
368
|
+
timeoutMs: HEALTH_CHECK_TIMEOUT_MS,
|
|
369
|
+
checks: () => [
|
|
370
|
+
...analyzedConfiguration.databases.map((database) => ({
|
|
371
|
+
kind: "database",
|
|
372
|
+
name: database.name,
|
|
373
|
+
probe: async () => {
|
|
374
|
+
const connection = databasesConnections[database.name];
|
|
375
|
+
if (!connection) return false;
|
|
376
|
+
await pingConnection(connection, database.type);
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
})),
|
|
380
|
+
...(redisInUse
|
|
381
|
+
? [{ kind: "redis", probe: async () => (await getCacheRedisClient().ping()) === "PONG" }]
|
|
382
|
+
: []),
|
|
383
|
+
...(queueManager?.connections() ?? []).map(({ type, name, connected }) => ({
|
|
384
|
+
kind: type,
|
|
385
|
+
name,
|
|
386
|
+
probe: () => connected,
|
|
387
|
+
})),
|
|
388
|
+
],
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Prometheus exposition, opt-in via METRICS_ENABLED. Gated: the series name
|
|
393
|
+
// operations, roles and databases.
|
|
394
|
+
if (env.metrics.enabled) {
|
|
395
|
+
Object.assign(
|
|
396
|
+
routes,
|
|
397
|
+
createMetricsRoute({
|
|
398
|
+
path: prefixes.metrics,
|
|
399
|
+
secretHeader: env.admin.header,
|
|
400
|
+
authorize: (candidate) => authorizeCapability(candidate, "metrics"),
|
|
401
|
+
render: renderMetrics,
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
301
406
|
// Console (admin UI + status APIs), opt-in via CONSOLE_ENABLED
|
|
302
407
|
if (env.console.enabled) {
|
|
303
408
|
const consoleHandler = () => html(consoleHtml);
|
|
@@ -320,51 +425,60 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
320
425
|
// GraphQL endpoint
|
|
321
426
|
routes[prefixes.graphql] = {
|
|
322
427
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
323
|
-
GET:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
428
|
+
GET: withHttpTracing(
|
|
429
|
+
"graphql",
|
|
430
|
+
withHttpMetrics(
|
|
431
|
+
"graphql",
|
|
432
|
+
withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
|
|
433
|
+
try {
|
|
434
|
+
if (req.headers.get("upgrade") === "websocket") {
|
|
435
|
+
const success = server.upgrade(req, {
|
|
436
|
+
data: {},
|
|
437
|
+
});
|
|
438
|
+
return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
|
|
439
|
+
}
|
|
440
|
+
return new S404({ error: "Not Found" });
|
|
441
|
+
} catch (error) {
|
|
442
|
+
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
443
|
+
}
|
|
444
|
+
}),
|
|
445
|
+
),
|
|
446
|
+
),
|
|
447
|
+
POST: withHttpTracing(
|
|
448
|
+
"graphql",
|
|
449
|
+
withHttpMetrics("graphql", async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
450
|
+
try {
|
|
451
|
+
const { gql, session, limit } = await getRoleHandlers(req, server);
|
|
452
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
453
|
+
|
|
454
|
+
const { query, variables } = await req.json();
|
|
455
|
+
|
|
456
|
+
if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
|
|
457
|
+
|
|
458
|
+
if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
|
|
459
|
+
|
|
460
|
+
const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
|
|
461
|
+
|
|
462
|
+
if (hasErrors)
|
|
463
|
+
return new S400({
|
|
464
|
+
errors: validationErrors.map((error) => ({
|
|
465
|
+
message: error.message,
|
|
466
|
+
locations: error.locations,
|
|
467
|
+
})),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return new S200(await gql.handler(query, variables, req, session));
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const message = (error as Error)?.message;
|
|
473
|
+
|
|
474
|
+
if (message === "Invalid username or password") {
|
|
475
|
+
return new S401({ errors: [{ message }] });
|
|
476
|
+
} else {
|
|
477
|
+
return new S400({ errors: [{ message }] });
|
|
478
|
+
}
|
|
365
479
|
}
|
|
366
|
-
}
|
|
367
|
-
|
|
480
|
+
}),
|
|
481
|
+
),
|
|
368
482
|
};
|
|
369
483
|
|
|
370
484
|
const aiEnabled = projectConfiguration.ai?.enabled ?? false;
|
|
@@ -380,7 +494,7 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
380
494
|
name: projectConfiguration.name,
|
|
381
495
|
version: projectConfiguration.version,
|
|
382
496
|
maxQueryDepth: env.ai?.mcp?.maxQueryDepth ?? env.maxQueryDepth,
|
|
383
|
-
|
|
497
|
+
authorize: authorizeCapability,
|
|
384
498
|
adminSecretHeader: env.admin.header,
|
|
385
499
|
});
|
|
386
500
|
|
|
@@ -395,7 +509,7 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
395
509
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
396
510
|
POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
397
511
|
try {
|
|
398
|
-
const { role, limit } = await getRoleHandlers(req, server);
|
|
512
|
+
const { role, session, scope, limit } = await getRoleHandlers(req, server, "ai");
|
|
399
513
|
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
400
514
|
if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
|
|
401
515
|
|
|
@@ -405,6 +519,13 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
405
519
|
errors: [{ message: "`prompt` (string) is required" }],
|
|
406
520
|
});
|
|
407
521
|
|
|
522
|
+
audit().emit({
|
|
523
|
+
action: "ai.ask",
|
|
524
|
+
actor: { ...actorFromSession(session), ...(scope ? { scope } : {}) },
|
|
525
|
+
target: { kind: "ai", via: "rest" },
|
|
526
|
+
prompt,
|
|
527
|
+
});
|
|
528
|
+
|
|
408
529
|
return new S200({ answer: await getAgent()(prompt) });
|
|
409
530
|
} catch (error) {
|
|
410
531
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
@@ -415,26 +536,29 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
415
536
|
}
|
|
416
537
|
|
|
417
538
|
// REST API endpoint
|
|
418
|
-
routes[`${prefixes.rest}/*`] =
|
|
419
|
-
|
|
539
|
+
routes[`${prefixes.rest}/*`] = withHttpTracing(
|
|
540
|
+
"rest",
|
|
541
|
+
withHttpMetrics("rest", async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
542
|
+
if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
|
|
420
543
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const urlParsed = new URL(req.url);
|
|
544
|
+
try {
|
|
545
|
+
const { rest, session, limit } = await getRoleHandlers(req, server);
|
|
546
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
426
547
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
548
|
+
const urlParsed = new URL(req.url);
|
|
549
|
+
|
|
550
|
+
return await rest.handler(
|
|
551
|
+
urlParsed,
|
|
552
|
+
urlParsed.pathname.replace(prefixes.rest, ""),
|
|
553
|
+
req.method,
|
|
554
|
+
req,
|
|
555
|
+
session,
|
|
556
|
+
);
|
|
557
|
+
} catch {
|
|
558
|
+
return new S400({ errors: [{ message: "Bad request" }] });
|
|
559
|
+
}
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
438
562
|
|
|
439
563
|
// Create WebSocket handler
|
|
440
564
|
const websocketHandler = websocketHandlerFactory(analyzedConfiguration.roles);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { Capability } from "../authentication/capabilities";
|
|
3
|
+
import type { SessionContext } from "../utils/sessionVariables";
|
|
4
|
+
|
|
5
|
+
import { logger } from "./index";
|
|
6
|
+
|
|
7
|
+
export type AuditActor = {
|
|
8
|
+
type: "admin_secret" | "console" | "token" | "credentials" | "anonymous";
|
|
9
|
+
sub?: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
/** Which credential was presented at a scoped surface: `all` is the admin secret. */
|
|
12
|
+
scope?: Capability | "all";
|
|
13
|
+
ip?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AuditEvent = {
|
|
17
|
+
action: string;
|
|
18
|
+
actor: AuditActor;
|
|
19
|
+
target: Record<string, unknown>;
|
|
20
|
+
outcome?: "success" | "failure";
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AuditLog = {
|
|
25
|
+
emit(event: AuditEvent): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const REDACTED_KEYS = new Set([
|
|
29
|
+
"password",
|
|
30
|
+
"secret",
|
|
31
|
+
"secrets",
|
|
32
|
+
"token",
|
|
33
|
+
"access_token",
|
|
34
|
+
"refresh_token",
|
|
35
|
+
"authorization",
|
|
36
|
+
"cookie",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const redact = (value: unknown): unknown => {
|
|
40
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
41
|
+
if (typeof value !== "object" || value === null) return value;
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.entries(value).map(([key, entry]) => [
|
|
44
|
+
key,
|
|
45
|
+
REDACTED_KEYS.has(key) ? "[REDACTED]" : redact(entry),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const actorFromSession = (session: SessionContext | undefined): AuditActor => {
|
|
51
|
+
if (!session) return { type: "anonymous" };
|
|
52
|
+
const type =
|
|
53
|
+
session.authMethod === "admin_secret" ? "admin_secret" : session.jti ? "token" : "anonymous";
|
|
54
|
+
return {
|
|
55
|
+
type,
|
|
56
|
+
...(session.sub !== undefined ? { sub: session.sub } : {}),
|
|
57
|
+
...(session.role !== undefined ? { role: session.role } : {}),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const createAuditLog = (base: Logger): AuditLog => {
|
|
62
|
+
// An audit record has to survive LOG_LEVEL=warn: the level is pinned here,
|
|
63
|
+
// independent of whatever the root logger was configured with.
|
|
64
|
+
const log = base.child({});
|
|
65
|
+
log.level = "info";
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
emit: (event) => {
|
|
69
|
+
log.info(redact(event) as object, event.action);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let override: AuditLog | null = null;
|
|
75
|
+
let instance: AuditLog | null = null;
|
|
76
|
+
|
|
77
|
+
/** Test seam: pass `null` to restore the default log. */
|
|
78
|
+
export const setAuditLog = (log: AuditLog | null): void => {
|
|
79
|
+
override = log;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const audit = (): AuditLog => {
|
|
83
|
+
if (override) return override;
|
|
84
|
+
if (!instance) instance = createAuditLog(logger("audit"));
|
|
85
|
+
return instance;
|
|
86
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
|
|
3
|
+
import { logger } from "./index";
|
|
4
|
+
|
|
5
|
+
/** The GraphQL operation a statement was generated for. Names only, never values. */
|
|
6
|
+
export type QuerySource = {
|
|
7
|
+
operation: {
|
|
8
|
+
type: "query" | "mutation" | "subscription";
|
|
9
|
+
name: string | null;
|
|
10
|
+
fields: string[];
|
|
11
|
+
};
|
|
12
|
+
role?: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SlowQueryRecord = {
|
|
16
|
+
durationMs: number;
|
|
17
|
+
dbType: string;
|
|
18
|
+
dbName: string;
|
|
19
|
+
sql?: string;
|
|
20
|
+
procedure?: string;
|
|
21
|
+
outcome: "success" | "error";
|
|
22
|
+
} & Partial<QuerySource>;
|
|
23
|
+
|
|
24
|
+
export type SlowQueryLog = {
|
|
25
|
+
emit(record: SlowQueryRecord & { thresholdMs: number }): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolved from `SLOW_QUERY_MS` at boot. `0` — also the value before boot sets
|
|
30
|
+
* it — reports nothing, so an executor used outside a booted server stays quiet.
|
|
31
|
+
*/
|
|
32
|
+
let thresholdMs = 0;
|
|
33
|
+
|
|
34
|
+
export const setSlowQueryMs = (ms: number): void => {
|
|
35
|
+
thresholdMs = ms;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const createSlowQueryLog = (base: Logger): SlowQueryLog => ({
|
|
39
|
+
emit: (record) => base.warn(record, "slow query"),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let override: SlowQueryLog | null = null;
|
|
43
|
+
let instance: SlowQueryLog | null = null;
|
|
44
|
+
|
|
45
|
+
/** Test seam: pass `null` to restore the default log. */
|
|
46
|
+
export const setSlowQueryLog = (log: SlowQueryLog | null): void => {
|
|
47
|
+
override = log;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const slowQueryLog = (): SlowQueryLog => {
|
|
51
|
+
if (override) return override;
|
|
52
|
+
if (!instance) instance = createSlowQueryLog(logger("slow-query"));
|
|
53
|
+
return instance;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const reportQueryDuration = (record: SlowQueryRecord): void => {
|
|
57
|
+
if (thresholdMs === 0 || record.durationMs <= thresholdMs) return;
|
|
58
|
+
slowQueryLog().emit({ ...record, thresholdMs });
|
|
59
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { logger } from "../logging";
|
|
2
|
+
import { S200, S503 } from "../utils/responses";
|
|
3
|
+
|
|
4
|
+
export type HealthCheck = {
|
|
5
|
+
kind: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
probe: () => Promise<boolean> | boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type HealthRoutesOptions = {
|
|
11
|
+
basePath: string;
|
|
12
|
+
/** Read on every readiness request, so a check can follow state that changes after boot. */
|
|
13
|
+
checks: () => HealthCheck[];
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const runCheck = async ({ probe }: HealthCheck, timeoutMs: number) => {
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
try {
|
|
20
|
+
return await Promise.race([
|
|
21
|
+
Promise.resolve().then(probe),
|
|
22
|
+
new Promise<never>((_, reject) => {
|
|
23
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs}ms`)), timeoutMs);
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unauthenticated probes for an orchestrator. Liveness touches nothing, so a
|
|
33
|
+
* dependency outage never gets the process restarted; readiness pings every
|
|
34
|
+
* dependency and answers 503 while one is down. The body names each check and
|
|
35
|
+
* whether it passed — the reason stays in the log, because a driver error can
|
|
36
|
+
* carry a host and port.
|
|
37
|
+
*/
|
|
38
|
+
export const createHealthRoutes = ({ basePath, checks, timeoutMs }: HealthRoutesOptions) => {
|
|
39
|
+
const log = logger("health");
|
|
40
|
+
|
|
41
|
+
const live = () => new S200({ status: "ok" });
|
|
42
|
+
|
|
43
|
+
const ready = async () => {
|
|
44
|
+
const results = await Promise.all(
|
|
45
|
+
checks().map(async (check) => {
|
|
46
|
+
let ok = false;
|
|
47
|
+
try {
|
|
48
|
+
ok = (await runCheck(check, timeoutMs)) === true;
|
|
49
|
+
if (!ok) log.warn({ kind: check.kind, name: check.name }, "readiness check failed");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
log.warn({ kind: check.kind, name: check.name, err: error }, "readiness check failed");
|
|
52
|
+
}
|
|
53
|
+
return { kind: check.kind, ...(check.name ? { name: check.name } : {}), ok };
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const healthy = results.every((result) => result.ok);
|
|
58
|
+
const body = { status: healthy ? "ok" : "unavailable", checks: results };
|
|
59
|
+
|
|
60
|
+
return healthy ? new S200(body) : new S503(body);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
[`${basePath}/live`]: live,
|
|
65
|
+
[`${basePath}/ready`]: ready,
|
|
66
|
+
};
|
|
67
|
+
};
|