@graphoria/server 0.3.0 → 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.
Files changed (87) hide show
  1. package/README.md +24 -20
  2. package/dist/src/ai/mcp/index.d.ts +9 -2
  3. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  4. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  5. package/dist/src/authentication/capabilities.d.ts +18 -0
  6. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  7. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  8. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  9. package/dist/src/authentication/jwt.d.ts.map +1 -1
  10. package/dist/src/authentication/paseto.d.ts.map +1 -1
  11. package/dist/src/authentication/secrets.d.ts +9 -0
  12. package/dist/src/authentication/secrets.d.ts.map +1 -0
  13. package/dist/src/authentication/secrets.test.d.ts +2 -0
  14. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  15. package/dist/src/configuration/getSchemas/index.d.ts +0 -2
  16. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  17. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +0 -1
  18. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  19. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  20. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  21. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  22. package/dist/src/configuration/index.d.ts +0 -1
  23. package/dist/src/configuration/index.d.ts.map +1 -1
  24. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  25. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  26. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  27. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  28. package/dist/src/console/api.d.ts.map +1 -1
  29. package/dist/src/console/session.d.ts +12 -4
  30. package/dist/src/console/session.d.ts.map +1 -1
  31. package/dist/src/databases/core/executor.d.ts +1 -1
  32. package/dist/src/databases/core/executor.d.ts.map +1 -1
  33. package/dist/src/databases/core/function-mapping.d.ts +1 -1
  34. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  35. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  36. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  37. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  38. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  39. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  40. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  41. package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
  42. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  43. package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
  44. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/logging/audit.d.ts +27 -0
  47. package/dist/src/logging/audit.d.ts.map +1 -0
  48. package/dist/src/logging/audit.test.d.ts +2 -0
  49. package/dist/src/logging/audit.test.d.ts.map +1 -0
  50. package/dist/src/singletons/env.d.ts +8 -4
  51. package/dist/src/singletons/env.d.ts.map +1 -1
  52. package/dist/src/types/env.d.ts +24 -12
  53. package/dist/src/types/env.d.ts.map +1 -1
  54. package/dist/src/utils/responses.d.ts +3 -0
  55. package/dist/src/utils/responses.d.ts.map +1 -1
  56. package/dist/src/utils/sessionVariables.d.ts +2 -0
  57. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +5 -5
  60. package/playgrounds/console/index.html +7 -7
  61. package/playgrounds/graphiql/index.html +548 -685
  62. package/src/ai/mcp/index.ts +19 -14
  63. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  64. package/src/authentication/capabilities.ts +54 -0
  65. package/src/authentication/index.ts +3 -3
  66. package/src/authentication/jwt.ts +30 -25
  67. package/src/authentication/paseto.ts +36 -26
  68. package/src/authentication/secrets.ts +30 -0
  69. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  70. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  71. package/src/configuration/gql/handleGraphQLRequestFactory.ts +17 -5
  72. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
  73. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  74. package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
  75. package/src/console/api.ts +63 -18
  76. package/src/console/session.ts +30 -26
  77. package/src/databases/core/executor.ts +1 -2
  78. package/src/databases/core/function-mapping.ts +0 -1
  79. package/src/databases/core/procedure-arguments.ts +25 -0
  80. package/src/databases/engines/mssql/connection.ts +0 -3
  81. package/src/databases/engines/mysql/connection.ts +10 -7
  82. package/src/databases/engines/postgresql/connection.ts +10 -7
  83. package/src/index.ts +51 -12
  84. package/src/logging/audit.ts +86 -0
  85. package/src/types/env.ts +26 -8
  86. package/src/utils/responses.ts +6 -0
  87. package/src/utils/sessionVariables.ts +2 -0
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,6 +16,7 @@ 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";
@@ -32,6 +35,7 @@ import {
32
35
  import { S200, S400, S401, S404, S429 } from "./utils/responses";
33
36
  import { writeSchema } from "./utils/writeSchema";
34
37
  import { logger, configureLogging } from "./logging";
38
+ import { actorFromSession, audit } from "./logging/audit";
35
39
 
36
40
  // Re-export for consumers
37
41
  export { configureLogging };
@@ -244,24 +248,52 @@ const createGraphQLServer = async (env: Env) => {
244
248
  : createMemoryRateLimitStore(),
245
249
  });
246
250
 
247
- // Helper to get role-based handlers
248
- const getRoleHandlers = async (req: Request, server?: Bun.Server<unknown>) => {
249
- const session = await getTokenService().verifyTokenAndGetSession(
250
- req.headers.get(env.authorizationHeader),
251
- req.headers.get(env.admin.header),
252
- );
251
+ const authorizeCapability = createCapabilityAuthorizer(env);
252
+
253
+ // Helper to get role-based handlers. A route that names a capability also
254
+ // accepts that capability's scoped credential in the admin-secret header;
255
+ // it stands in for the superadmin role on that route and nowhere else.
256
+ const getRoleHandlers = async (
257
+ req: Request,
258
+ server?: Bun.Server<unknown>,
259
+ capability?: Capability,
260
+ ) => {
261
+ const adminSecretHeader = req.headers.get(env.admin.header);
262
+ const grant = capability ? authorizeCapability(adminSecretHeader, capability) : null;
263
+
264
+ const session: SessionContext =
265
+ grant && !grant.superset
266
+ ? { sub: capability, role: env.superadmin.role, authMethod: "admin_secret" }
267
+ : await getTokenService().verifyTokenAndGetSession(
268
+ req.headers.get(env.authorizationHeader),
269
+ adminSecretHeader,
270
+ );
271
+
272
+ const ip = resolveClientAddress(req, server, env.rateLimit.trustProxy);
253
273
 
254
274
  // The limit needs the role, and the role costs a token verification plus a
255
275
  // revocation lookup — so it is spent here, once, rather than again inside a
256
276
  // wrapper.
257
- const limit = await rateLimiter?.check(
258
- session,
259
- resolveClientAddress(req, server, env.rateLimit.trustProxy),
260
- );
277
+ const limit = await rateLimiter?.check(session, ip);
278
+
279
+ const scope: Capability | "all" | undefined = grant
280
+ ? grant.superset
281
+ ? "all"
282
+ : capability
283
+ : undefined;
284
+
285
+ if (session.authMethod === "admin_secret") {
286
+ audit().emit({
287
+ action: "admin_secret.used",
288
+ actor: { type: "admin_secret", ...(scope ? { scope } : {}), ip },
289
+ target: { kind: "endpoint", method: req.method, path: new URL(req.url).pathname },
290
+ });
291
+ }
261
292
 
262
293
  return {
263
294
  role: session.role!,
264
295
  session,
296
+ scope,
265
297
  limit,
266
298
  ...analyzedConfiguration.roles[session.role!].handlers,
267
299
  };
@@ -380,7 +412,7 @@ const createGraphQLServer = async (env: Env) => {
380
412
  name: projectConfiguration.name,
381
413
  version: projectConfiguration.version,
382
414
  maxQueryDepth: env.ai?.mcp?.maxQueryDepth ?? env.maxQueryDepth,
383
- adminSecret: env.admin.secret,
415
+ authorize: authorizeCapability,
384
416
  adminSecretHeader: env.admin.header,
385
417
  });
386
418
 
@@ -395,7 +427,7 @@ const createGraphQLServer = async (env: Env) => {
395
427
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
396
428
  POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
397
429
  try {
398
- const { role, limit } = await getRoleHandlers(req, server);
430
+ const { role, session, scope, limit } = await getRoleHandlers(req, server, "ai");
399
431
  if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
400
432
  if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
401
433
 
@@ -405,6 +437,13 @@ const createGraphQLServer = async (env: Env) => {
405
437
  errors: [{ message: "`prompt` (string) is required" }],
406
438
  });
407
439
 
440
+ audit().emit({
441
+ action: "ai.ask",
442
+ actor: { ...actorFromSession(session), ...(scope ? { scope } : {}) },
443
+ target: { kind: "ai", via: "rest" },
444
+ prompt,
445
+ });
446
+
408
447
  return new S200({ answer: await getAgent()(prompt) });
409
448
  } catch (error) {
410
449
  return new S400({ errors: [{ message: (error as Error)?.message }] });
@@ -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
+ };
package/src/types/env.ts CHANGED
@@ -3,19 +3,29 @@ import { z } from "zod";
3
3
  import { version } from "../../package.json";
4
4
  import { ConfigurationZod } from "./zod/configuration";
5
5
 
6
+ // A secret env var is a comma-separated list: the first entry is the one in
7
+ // use, the rest are still accepted so a rotation can overlap instead of cutting
8
+ // over. Blank entries are dropped, so an unset var is an empty list.
9
+ const splitSecrets = (value: string) =>
10
+ value
11
+ .split(",")
12
+ .map((entry) => entry.trim())
13
+ .filter(Boolean);
14
+ const secretList = z.string().default("").transform(splitSecrets);
15
+
6
16
  export const EnvZod = z
7
17
  .object({
8
18
  PORT: z.coerce.number().default(3000),
9
19
  NODE_ENV: z.string().default("DEVELOPMENT"),
10
- ADMIN_SECRET: z.string(),
20
+ ADMIN_SECRET: z.string().transform(splitSecrets),
11
21
  CONFIGURATION: z.union([z.string(), ConfigurationZod]).optional(),
12
22
  ANONYMOUS_ROLE: z.string().default("anonymous"),
13
- JWT_SECRET: z.string().optional().default(""),
23
+ JWT_SECRET: secretList,
14
24
  JWT_EXPIRES_IN: z.string().default("5m"),
15
25
  JWT_RT_EXPIRES_IN: z.string().default("7d"),
16
- PASETO_LOCAL_KEY: z.string().optional().default(""),
26
+ PASETO_LOCAL_KEY: secretList,
17
27
  PASETO_SECRET_KEY: z.string().optional().default(""),
18
- PASETO_PUBLIC_KEY: z.string().optional().default(""),
28
+ PASETO_PUBLIC_KEY: secretList,
19
29
  AUTH_STRATEGY: z.enum(["jwt", "paseto_local", "paseto_public"]).optional(),
20
30
  REDIS_URL: z.string().default("redis://localhost:6379"),
21
31
  CACHE_STORE: z.enum(["memory", "redis"]).default("memory"),
@@ -31,6 +41,8 @@ export const EnvZod = z
31
41
  CONSOLE_ENABLED: z.stringbool().default(false),
32
42
  CONSOLE_ENDPOINT: z.string().default("/_console"),
33
43
  CONSOLE_SESSION_EXPIRES_IN: z.string().default("1h"),
44
+ CONSOLE_READ_SECRET: secretList,
45
+ CONSOLE_WRITE_SECRET: secretList,
34
46
  PREFIX: z.string().default(""),
35
47
  CORS_ENABLED: z.stringbool().default(true),
36
48
  PRINT_SCHEMAS: z.stringbool().default(false),
@@ -48,9 +60,11 @@ export const EnvZod = z
48
60
  AI_ENABLED: z.stringbool().optional(),
49
61
  AI_GRAPHQL_ENABLED: z.stringbool().default(true),
50
62
  AI_REST_ENABLED: z.stringbool().default(true),
63
+ AI_SECRET: secretList,
51
64
  AI_MCP_ENABLED: z.stringbool().optional(),
52
65
  AI_MCP_ENDPOINT: z.string().default("/mcp"),
53
66
  AI_MCP_REQUIRE_ADMIN_SECRET: z.stringbool().default(false),
67
+ AI_MCP_SECRET: secretList,
54
68
  AI_MCP_MAX_QUERY_DEPTH: z.coerce.number().int().min(0).optional(),
55
69
  AI_MCP_DISABLED_TOOLS: z.string().default(""),
56
70
  AI_MCP_DISABLED_RESOURCES: z.string().default(""),
@@ -80,18 +94,18 @@ export const EnvZod = z
80
94
  queryTimeoutMs: env.QUERY_TIMEOUT_MS,
81
95
  maxQueryCost: env.MAX_QUERY_COST,
82
96
  admin: {
83
- secret: env.ADMIN_SECRET,
97
+ secrets: env.ADMIN_SECRET,
84
98
  header: env.ADMIN_SECRET_HEADER,
85
99
  },
86
100
  jwt: {
87
- secret: env.JWT_SECRET,
101
+ secrets: env.JWT_SECRET,
88
102
  expiresIn: env.JWT_EXPIRES_IN,
89
103
  rtExpiresIn: env.JWT_RT_EXPIRES_IN,
90
104
  },
91
105
  paseto: {
92
- localKey: env.PASETO_LOCAL_KEY,
106
+ localKeys: env.PASETO_LOCAL_KEY,
93
107
  secretKey: env.PASETO_SECRET_KEY,
94
- publicKey: env.PASETO_PUBLIC_KEY,
108
+ publicKeys: env.PASETO_PUBLIC_KEY,
95
109
  },
96
110
  rateLimit: {
97
111
  max: env.RATE_LIMIT_MAX,
@@ -114,15 +128,19 @@ export const EnvZod = z
114
128
  enabled: env.CONSOLE_ENABLED,
115
129
  endpoint: env.CONSOLE_ENDPOINT,
116
130
  sessionExpiresIn: env.CONSOLE_SESSION_EXPIRES_IN,
131
+ readSecrets: env.CONSOLE_READ_SECRET,
132
+ writeSecrets: env.CONSOLE_WRITE_SECRET,
117
133
  },
118
134
  ai: {
119
135
  enabled: env.AI_ENABLED,
120
136
  graphqlEnabled: env.AI_GRAPHQL_ENABLED,
121
137
  restEnabled: env.AI_REST_ENABLED,
138
+ secrets: env.AI_SECRET,
122
139
  mcp: {
123
140
  enabled: env.AI_MCP_ENABLED,
124
141
  endpoint: env.AI_MCP_ENDPOINT,
125
142
  requireAdminSecret: env.AI_MCP_REQUIRE_ADMIN_SECRET,
143
+ secrets: env.AI_MCP_SECRET,
126
144
  maxQueryDepth: env.AI_MCP_MAX_QUERY_DEPTH,
127
145
  disabledTools: env.AI_MCP_DISABLED_TOOLS
128
146
  ? env.AI_MCP_DISABLED_TOOLS.split(",")
@@ -29,6 +29,12 @@ export class S401 extends ClientResponse {
29
29
  }
30
30
  }
31
31
 
32
+ export class S403 extends ClientResponse {
33
+ constructor(body?: object | null, init?: ResponseInit) {
34
+ super(body, { ...init, status: 403 });
35
+ }
36
+ }
37
+
32
38
  export class S404 extends ClientResponse {
33
39
  constructor(body?: object | null, init?: ResponseInit) {
34
40
  super(body, { ...init, status: 404 });
@@ -13,6 +13,8 @@ export type SessionContext = {
13
13
  iat?: number;
14
14
  exp?: number;
15
15
  nbf?: number;
16
+ /** Set when the admin secret, not a token, produced this session. */
17
+ authMethod?: "admin_secret";
16
18
  [key: string]: unknown;
17
19
  };
18
20