@grapity/grapity 0.2.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.
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { Command as Command20 } from "commander";
4
+ import { Command as Command21 } from "commander";
5
5
 
6
6
  // src/cli/commands/registry/index.ts
7
7
  import { Command as Command8 } from "commander";
@@ -18,14 +18,19 @@ import fs from "fs";
18
18
  import os from "os";
19
19
  import path from "path";
20
20
  import yaml from "js-yaml";
21
+ var CONFIG_PATH = () => path.join(os.homedir(), ".grapity", "config.yaml");
21
22
  var DEFAULT_CONFIG = {
22
23
  mode: "local",
23
24
  local: {
24
- port: 3750
25
+ port: 3750,
26
+ database: "sqlite"
25
27
  }
26
28
  };
29
+ function configExists() {
30
+ return fs.existsSync(CONFIG_PATH());
31
+ }
27
32
  function getConfig() {
28
- const configPath = path.join(os.homedir(), ".grapity", "config.yaml");
33
+ const configPath = CONFIG_PATH();
29
34
  if (!fs.existsSync(configPath)) {
30
35
  return DEFAULT_CONFIG;
31
36
  }
@@ -40,12 +45,19 @@ function getConfig() {
40
45
  return DEFAULT_CONFIG;
41
46
  }
42
47
  const config = parsed;
48
+ let database = config.local?.database ?? DEFAULT_CONFIG.local.database;
49
+ if (!config.local?.database && config.local?.sqlitePath) {
50
+ database = "sqlite";
51
+ }
43
52
  return {
44
53
  mode: config.mode ?? DEFAULT_CONFIG.mode,
45
54
  remote: config.remote,
46
55
  local: {
47
56
  port: config.local?.port ?? DEFAULT_CONFIG.local.port,
48
- sqlitePath: config.local?.sqlitePath
57
+ database,
58
+ sqlitePath: config.local?.sqlitePath,
59
+ postgresUrl: config.local?.postgresUrl,
60
+ auth: config.local?.auth
49
61
  }
50
62
  };
51
63
  }
@@ -56,6 +68,203 @@ function getRegistryUrl() {
56
68
  }
57
69
  return `http://localhost:${config.local?.port ?? 3750}`;
58
70
  }
71
+ function isPostgresqlUrl(value) {
72
+ return value.startsWith("postgresql://") || value.startsWith("postgres://");
73
+ }
74
+
75
+ // src/cli/auth.ts
76
+ import { decodeJwt } from "jose";
77
+
78
+ // src/registry/auth/middleware.ts
79
+ import { createRemoteJWKSet, jwtVerify } from "jose";
80
+ var AuthError = class extends Error {
81
+ constructor(statusCode, code, message) {
82
+ super(message);
83
+ this.statusCode = statusCode;
84
+ this.code = code;
85
+ this.name = "AuthError";
86
+ }
87
+ statusCode;
88
+ code;
89
+ };
90
+ function buildKeycloakUrls(config) {
91
+ const base = `${config.serverUrl}/realms/${config.realm}`;
92
+ return {
93
+ issuer: base,
94
+ jwksUri: `${base}/protocol/openid-connect/certs`,
95
+ tokenUrl: `${base}/protocol/openid-connect/token`
96
+ };
97
+ }
98
+ function createAuthMiddleware(config, routeScopes) {
99
+ if (config.auth?.mode !== "keycloak") {
100
+ return async (_c, next) => await next();
101
+ }
102
+ const authConfig = config.auth;
103
+ const { issuer, jwksUri } = buildKeycloakUrls(authConfig);
104
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
105
+ const scopeByRoute = /* @__PURE__ */ new Map();
106
+ for (const route of routeScopes) {
107
+ const key = `${route.method.toUpperCase()}:${route.path}`;
108
+ scopeByRoute.set(key, {
109
+ operationId: route.operationId,
110
+ scopes: route.scopes
111
+ });
112
+ }
113
+ return async (c2, next) => {
114
+ const matchedPath = c2.req.matchedRoutes.map((r) => r.path).filter((p) => p !== "/*").pop();
115
+ const routeKey = matchedPath ? `${c2.req.method}:${matchedPath}` : `${c2.req.method}:${c2.req.routePath}`;
116
+ const required = scopeByRoute.get(routeKey);
117
+ if (!required || required.scopes.length === 0) {
118
+ return await next();
119
+ }
120
+ const authHeader = c2.req.header("Authorization");
121
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
122
+ throw new AuthError(401, "unauthorized", "Bearer token required");
123
+ }
124
+ const token = authHeader.slice("Bearer ".length).trim();
125
+ if (!token) {
126
+ throw new AuthError(401, "unauthorized", "Bearer token required");
127
+ }
128
+ let payload;
129
+ try {
130
+ const result = await jwtVerify(token, jwks, {
131
+ issuer,
132
+ audience: authConfig.audience
133
+ });
134
+ payload = result.payload;
135
+ } catch (err) {
136
+ const message = err instanceof Error ? err.message : "Invalid token";
137
+ throw new AuthError(401, "unauthorized", `Invalid or expired token: ${message}`);
138
+ }
139
+ const subject = payload.sub;
140
+ if (!subject) {
141
+ throw new AuthError(401, "unauthorized", "Token missing subject claim");
142
+ }
143
+ const grantedScopes = extractScopes(payload, authConfig.roleSource ?? "scope");
144
+ const missing = required.scopes.filter((s) => !grantedScopes.has(s));
145
+ if (missing.length > 0) {
146
+ throw new AuthError(
147
+ 403,
148
+ "forbidden",
149
+ `Missing required scope${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`
150
+ );
151
+ }
152
+ c2.set("actor", subject);
153
+ c2.set("claims", payload);
154
+ await next();
155
+ };
156
+ }
157
+ function extractScopes(payload, source) {
158
+ if (source === "realm_access.roles") {
159
+ const roles = payload.realm_access?.roles;
160
+ return new Set(roles ?? []);
161
+ }
162
+ const scopeValue = payload.scope;
163
+ if (typeof scopeValue === "string") {
164
+ return new Set(scopeValue.split(/\s+/).filter(Boolean));
165
+ }
166
+ return /* @__PURE__ */ new Set();
167
+ }
168
+ function parseRouteScopes(spec) {
169
+ const routes = [];
170
+ const paths = spec.paths;
171
+ if (!paths) return routes;
172
+ for (const [path12, operations] of Object.entries(paths)) {
173
+ for (const [method, operation] of Object.entries(operations)) {
174
+ if (typeof operation !== "object" || operation === null) continue;
175
+ const op = operation;
176
+ const operationId = op.operationId;
177
+ if (!operationId) continue;
178
+ const security = op.security;
179
+ const scopes = [];
180
+ if (security) {
181
+ for (const sec of security) {
182
+ for (const [name, required] of Object.entries(sec)) {
183
+ if (name === "keycloak" && Array.isArray(required)) {
184
+ scopes.push(...required);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ routes.push({
190
+ method: method.toUpperCase(),
191
+ path: path12.replace(/\{([^}]+)\}/g, ":$1"),
192
+ operationId,
193
+ scopes
194
+ });
195
+ }
196
+ }
197
+ return routes;
198
+ }
199
+
200
+ // src/cli/auth.ts
201
+ var cachedToken = null;
202
+ function resetTokenCache() {
203
+ cachedToken = null;
204
+ }
205
+ async function getAccessToken(config) {
206
+ const staticToken = process.env.GRAPITY_TOKEN;
207
+ if (staticToken) {
208
+ return staticToken;
209
+ }
210
+ const now = Math.floor(Date.now() / 1e3);
211
+ if (cachedToken && cachedToken.expiresAt > now + 60) {
212
+ return cachedToken.accessToken;
213
+ }
214
+ const clientSecret = process.env.GRAPITY_CLIENT_SECRET;
215
+ if (!clientSecret) {
216
+ throw new Error(
217
+ "Keycloak client secret not found. Set GRAPITY_CLIENT_SECRET or provide a static token via GRAPITY_TOKEN."
218
+ );
219
+ }
220
+ const { tokenUrl } = buildKeycloakUrls({ serverUrl: config.serverUrl, realm: config.realm });
221
+ const params = new URLSearchParams();
222
+ params.set("grant_type", "client_credentials");
223
+ params.set("client_id", config.clientId);
224
+ params.set("client_secret", clientSecret);
225
+ if (config.audience) {
226
+ params.set("audience", config.audience);
227
+ }
228
+ const response = await fetch(tokenUrl, {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
231
+ body: params.toString()
232
+ });
233
+ if (!response.ok) {
234
+ const text3 = await response.text().catch(() => "Unknown error");
235
+ throw new Error(`Keycloak token request failed: ${response.status} ${text3}`);
236
+ }
237
+ const data = await response.json();
238
+ cachedToken = {
239
+ accessToken: data.access_token,
240
+ expiresAt: now + data.expires_in
241
+ };
242
+ return data.access_token;
243
+ }
244
+ function decodeToken(token) {
245
+ try {
246
+ const payload = decodeJwt(token);
247
+ return {
248
+ sub: payload.sub,
249
+ exp: payload.exp,
250
+ iss: payload.iss,
251
+ scope: typeof payload.scope === "string" ? payload.scope : void 0,
252
+ roles: payload.realm_access?.roles
253
+ };
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+ function formatTokenStatus(token) {
259
+ const decoded = decodeToken(token);
260
+ const now = Math.floor(Date.now() / 1e3);
261
+ return {
262
+ valid: !!decoded.sub,
263
+ sub: decoded.sub,
264
+ exp: decoded.exp,
265
+ expired: decoded.exp ? decoded.exp < now : void 0
266
+ };
267
+ }
59
268
 
60
269
  // src/cli/client.ts
61
270
  var BreakingChangeError = class extends Error {
@@ -66,19 +275,31 @@ var BreakingChangeError = class extends Error {
66
275
  }
67
276
  compatReport;
68
277
  };
278
+ async function getAuthHeaders() {
279
+ const config = getConfig();
280
+ const authConfig = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
281
+ const headers = {};
282
+ if (authConfig?.mode === "keycloak") {
283
+ if (!authConfig.clientId) {
284
+ throw new Error(
285
+ "Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
286
+ );
287
+ }
288
+ const token = await getAccessToken(authConfig);
289
+ headers["Authorization"] = `Bearer ${token}`;
290
+ }
291
+ return headers;
292
+ }
69
293
  async function request(method, path12, body) {
70
294
  const baseUrl = getRegistryUrl();
71
295
  const url = `${baseUrl}${path12}`;
72
- const config = getConfig();
73
- const headers = {
74
- "Content-Type": "application/json"
75
- };
76
- if (config.mode === "remote" && config.remote?.apiKey) {
77
- headers["X-API-Key"] = config.remote.apiKey;
78
- }
296
+ const authHeaders = await getAuthHeaders();
79
297
  const response = await fetch(url, {
80
298
  method,
81
- headers,
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ ...authHeaders
302
+ },
82
303
  body: body ? JSON.stringify(body) : void 0
83
304
  });
84
305
  if (!response.ok) {
@@ -88,18 +309,17 @@ async function request(method, path12, body) {
88
309
  }
89
310
  throw new Error(error.message ?? `Request failed: ${response.status}`);
90
311
  }
91
- const text2 = await response.text();
92
- return text2 ? JSON.parse(text2) : void 0;
312
+ const text3 = await response.text();
313
+ return text3 ? JSON.parse(text3) : void 0;
93
314
  }
94
315
  async function requestText(method, path12) {
95
316
  const baseUrl = getRegistryUrl();
96
317
  const url = `${baseUrl}${path12}`;
97
- const config = getConfig();
98
- const headers = {};
99
- if (config.mode === "remote" && config.remote?.apiKey) {
100
- headers["X-API-Key"] = config.remote.apiKey;
101
- }
102
- const response = await fetch(url, { method, headers });
318
+ const authHeaders = await getAuthHeaders();
319
+ const response = await fetch(url, {
320
+ method,
321
+ headers: authHeaders
322
+ });
103
323
  if (!response.ok) {
104
324
  const error = await response.json();
105
325
  throw new Error(error.message ?? `Request failed: ${response.status}`);
@@ -150,9 +370,9 @@ var client = {
150
370
  fetchSpec: async (name, options) => {
151
371
  const format = options.format ?? "yaml";
152
372
  const path12 = options.semver ? `/v1/specs/${name}/versions/${options.semver}/spec.${format}` : `/v1/specs/${name}/spec.${format}`;
153
- const { text: text2, headers } = await requestText("GET", path12);
373
+ const { text: text3, headers } = await requestText("GET", path12);
154
374
  return {
155
- content: text2,
375
+ content: text3,
156
376
  resolvedVersion: headers.get("Grapity-Resolved-Version") ?? void 0
157
377
  };
158
378
  },
@@ -531,27 +751,65 @@ function formatInitSuccess(params) {
531
751
  ];
532
752
  if (params.mode === "local") {
533
753
  if (params.port) lines.push(` ${c.label("Port")} ${c.cyan(String(params.port))}`);
534
- if (params.dbPath) lines.push(` ${c.label("Database")} ${c.dim(params.dbPath)}`);
535
- lines.push("");
536
- lines.push(` ${c.dim("\u203A")} Start the server with: ${c.primary("grapity serve")}`);
754
+ if (params.database) lines.push(` ${c.label("Database")} ${c.primary(params.database)}`);
755
+ if (params.dbPath) lines.push(` ${c.label("Path")} ${c.dim(params.dbPath)}`);
756
+ if (params.postgresUrl) lines.push(` ${c.label("PostgreSQL")} ${c.dim(params.postgresUrl)}`);
537
757
  } else {
538
758
  if (params.url) lines.push(` ${c.label("URL")} ${c.cyan(params.url)}`);
539
- if (params.hasApiKey) lines.push(` ${c.label("API key")} ${c.dim("configured")}`);
759
+ }
760
+ if (params.authMode && params.authMode !== "none") {
761
+ lines.push("");
762
+ lines.push(` ${c.label("Auth")} ${c.primary(params.authMode)}`);
763
+ if (params.keycloakServer) lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
764
+ if (params.keycloakRealm) lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
765
+ if (params.keycloakClientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.keycloakClientId)}`);
540
766
  lines.push("");
541
- lines.push(` ${c.dim("\u203A")} Push a spec with: ${c.primary("grapity registry push ./openapi.yaml --name my-api")}`);
767
+ lines.push(` ${c.dim("\u203A")} Set the client secret in GRAPITY_CLIENT_SECRET before running commands.`);
768
+ } else {
769
+ lines.push("");
770
+ if (params.mode === "local") {
771
+ lines.push(` ${c.dim("\u203A")} Start the server with: ${c.primary("grapity serve")}`);
772
+ } else {
773
+ lines.push(` ${c.dim("\u203A")} Push a spec with: ${c.primary("grapity registry push ./openapi.yaml --name my-api")}`);
774
+ }
542
775
  }
543
776
  return lines.join("\n");
544
777
  }
545
778
  function formatServeConfig(params) {
546
- const modeLabel = `local ${c.label("\xB7")} ${params.mode}`;
547
779
  const lines = [
548
- ` ${c.label("Mode")} ${c.primary(modeLabel)}`,
549
- ` ${c.label("Port")} ${c.cyan(String(params.port))}`
780
+ ` ${c.label("Mode")} ${c.primary("local")}`,
781
+ ` ${c.label("Port")} ${c.cyan(String(params.port))}`,
782
+ ` ${c.label("Database")} ${c.primary(params.database)}`
550
783
  ];
551
- if (params.dbPath) lines.push(` ${c.label("Database")} ${c.dim(params.dbPath)}`);
552
- lines.push(` ${c.label("Auth")} ${c.primary(params.auth)}`);
784
+ if (params.database === "sqlite" && params.dbPath) {
785
+ lines.push(` ${c.label("Path")} ${c.dim(params.dbPath)}`);
786
+ }
787
+ if (params.database === "postgresql" && params.postgresUrl) {
788
+ lines.push(` ${c.label("PostgreSQL")} ${c.dim(maskPostgresUrl(params.postgresUrl))}`);
789
+ }
790
+ lines.push(` ${c.label("Auth")} ${c.primary(params.authMode ?? "none")}`);
791
+ if (params.keycloakServer) {
792
+ lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
793
+ }
794
+ if (params.keycloakRealm) {
795
+ lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
796
+ }
797
+ if (params.keycloakAudience) {
798
+ lines.push(` ${c.label("Audience")} ${c.dim(params.keycloakAudience)}`);
799
+ }
553
800
  return lines.join("\n");
554
801
  }
802
+ function maskPostgresUrl(url) {
803
+ try {
804
+ const parsed = new URL(url);
805
+ if (parsed.password) {
806
+ parsed.password = "***";
807
+ }
808
+ return parsed.toString();
809
+ } catch {
810
+ return url;
811
+ }
812
+ }
555
813
  function formatHubConfig(params) {
556
814
  const lines = [
557
815
  ` ${c.label("Port")} ${c.cyan(String(params.port))}`,
@@ -582,6 +840,39 @@ function formatEmptyState(message, hints) {
582
840
  function formatReady(port) {
583
841
  return ` ${c.success("\u25CF")} Server ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
584
842
  }
843
+ function formatAuthStatus(params) {
844
+ const lines = [];
845
+ if (params.cleared) {
846
+ lines.push(` ${c.success("\u2713")} Cached access token cleared`);
847
+ return lines.join("\n");
848
+ }
849
+ lines.push(` ${c.label("Mode")} ${c.primary(params.mode)}`);
850
+ if (!params.configured) {
851
+ lines.push(` ${c.dim("\xB7")} No remote authentication configured`);
852
+ return lines.join("\n");
853
+ }
854
+ if (params.serverUrl) lines.push(` ${c.label("Server")} ${c.dim(params.serverUrl)}`);
855
+ if (params.realm) lines.push(` ${c.label("Realm")} ${c.dim(params.realm)}`);
856
+ if (params.clientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.clientId)}`);
857
+ if (params.audience) lines.push(` ${c.label("Audience")} ${c.dim(params.audience)}`);
858
+ lines.push("");
859
+ if (params.valid === false) {
860
+ lines.push(` ${c.error("\u2717")} Token is not a valid JWT`);
861
+ } else if (params.sub) {
862
+ lines.push(` ${c.success("\u2713")} Authenticated as ${c.primary(params.sub)}`);
863
+ if (params.exp) {
864
+ const date = new Date(params.exp * 1e3).toISOString();
865
+ if (params.expired) {
866
+ lines.push(` ${c.error("\u2717")} Token expired at ${c.dim(date)}`);
867
+ } else {
868
+ lines.push(` ${c.success("\u2713")} Token valid until ${c.dim(date)}`);
869
+ }
870
+ }
871
+ } else {
872
+ lines.push(` ${c.dim("\xB7")} No token fetched yet`);
873
+ }
874
+ return lines.join("\n");
875
+ }
585
876
  function formatHubReady(port) {
586
877
  return ` ${c.success("\u25CF")} Hub ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
587
878
  }
@@ -744,18 +1035,18 @@ var validateCommand = new Command2("validate").description("Validate a spec agai
744
1035
  import { Command as Command3 } from "commander";
745
1036
  var listCommand = new Command3("list").description("List all specs in the registry").option("--type <type>", "Filter by spec type").option("--owner <owner>", "Filter by owner").option("--tags <tags>", "Filter by tags (comma-separated)").action(async (options) => {
746
1037
  try {
747
- const specs2 = await client.listSpecs({
1038
+ const specs3 = await client.listSpecs({
748
1039
  type: options.type,
749
1040
  owner: options.owner,
750
1041
  tags: options.tags?.split(",")
751
1042
  });
752
- if (specs2.length === 0) {
1043
+ if (specs3.length === 0) {
753
1044
  console.log(formatEmptyState("No specs in the registry.", [
754
1045
  "Push one with: grapity registry push ./openapi.yaml --name my-api"
755
1046
  ]));
756
1047
  return;
757
1048
  }
758
- for (const spec of specs2) {
1049
+ for (const spec of specs3) {
759
1050
  console.log(formatSpec(spec));
760
1051
  }
761
1052
  } catch (err) {
@@ -1145,10 +1436,10 @@ function convertPathToKong(path12) {
1145
1436
  }
1146
1437
  return converted;
1147
1438
  }
1148
- function routeName(serviceName, index2, path12, methods) {
1439
+ function routeName(serviceName, index3, path12, methods) {
1149
1440
  const cleanPath = path12.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1150
1441
  const methodList = methods.join("-").toLowerCase();
1151
- return `${serviceName}-${cleanPath}-${methodList}-${index2}`;
1442
+ return `${serviceName}-${cleanPath}-${methodList}-${index3}`;
1152
1443
  }
1153
1444
  function generateDeckYaml(config, envName) {
1154
1445
  const env = config.environments[envName];
@@ -1380,7 +1671,7 @@ import fs8 from "fs";
1380
1671
  import os3 from "os";
1381
1672
  import path8 from "path";
1382
1673
  import yaml4 from "js-yaml";
1383
- var initCommand = new Command18("init").description("Configure grapity registry (local or remote mode)").option("--local", "Use local mode (SQLite)").option("--remote", "Use remote mode (connect to a grapity server)").option("--url <url>", "Registry URL (for remote mode)").option("--api-key <key>", "API key (for remote mode)").option("--port <port>", "Port for local server (default: 3750)").option("--db <path>", "Path to SQLite database file (for local mode)").action(async (options) => {
1674
+ var initCommand = new Command18("init").description("Configure grapity registry (local or remote mode)").option("--local", "Use local mode (SQLite or PostgreSQL)").option("--remote", "Use remote mode (connect to a grapity server)").option("--url <url>", "Registry URL (for remote mode)").option("--port <port>", "Port for local server (default: 3750)").option("--db <path-or-url>", "SQLite path or postgresql:// URL (for local mode)").option("--auth <mode>", "Auth mode: none | keycloak (default: none)").option("--keycloak-server <url>", "Keycloak server URL").option("--keycloak-realm <realm>", "Keycloak realm").option("--keycloak-client-id <id>", "Keycloak client ID (for CLI client credentials)").option("--keycloak-audience <audience>", "Keycloak token audience to validate").option("--keycloak-role-source <source>", "Where to read roles from: scope | realm_access.roles (default: scope)").action(async (options) => {
1384
1675
  const configDir = path8.join(os3.homedir(), ".grapity");
1385
1676
  const configPath = path8.join(configDir, "config.yaml");
1386
1677
  let mode;
@@ -1398,21 +1689,47 @@ var initCommand = new Command18("init").description("Configure grapity registry
1398
1689
  "missing flag",
1399
1690
  "Select registry mode: use --local or --remote.",
1400
1691
  [
1401
- "--local Run a registry server on this machine (SQLite)",
1692
+ "--local Run a registry server on this machine",
1402
1693
  "--remote Connect to an existing grapity server"
1403
1694
  ]
1404
1695
  )
1405
1696
  );
1406
1697
  process.exit(1);
1407
1698
  }
1699
+ const authMode = options.auth ?? "none";
1700
+ if (authMode !== "none" && authMode !== "keycloak") {
1701
+ console.error(formatError("invalid auth mode", "--auth must be one of: none, keycloak"));
1702
+ process.exit(1);
1703
+ }
1704
+ const keycloakAuth = authMode === "keycloak" ? parseKeycloakOptions(options, mode) : void 0;
1408
1705
  const config = { mode };
1409
1706
  if (mode === "local") {
1707
+ const dbValue = options.db ?? process.env.GRAPITY_DATABASE_URL;
1708
+ const database = dbValue && isPostgresqlUrl(dbValue) ? "postgresql" : "sqlite";
1410
1709
  config.local = {
1411
1710
  port: options.port ? parseInt(options.port, 10) : 3750,
1412
- sqlitePath: options.db
1711
+ database
1413
1712
  };
1414
- if (!config.local.sqlitePath) {
1415
- config.local.sqlitePath = path8.join(os3.homedir(), ".grapity", "registry.db");
1713
+ if (database === "postgresql") {
1714
+ config.local.postgresUrl = dbValue;
1715
+ } else {
1716
+ config.local.sqlitePath = dbValue ?? path8.join(os3.homedir(), ".grapity", "registry.db");
1717
+ }
1718
+ if (keycloakAuth) {
1719
+ if (!options.keycloakClientId) {
1720
+ console.error(
1721
+ formatError(
1722
+ "missing flag",
1723
+ "--keycloak-client-id is required when using Keycloak auth."
1724
+ )
1725
+ );
1726
+ process.exit(1);
1727
+ }
1728
+ config.local.auth = {
1729
+ mode: "keycloak",
1730
+ clientId: options.keycloakClientId,
1731
+ ...keycloakAuth
1732
+ };
1416
1733
  }
1417
1734
  } else {
1418
1735
  if (!options.url) {
@@ -1426,9 +1743,18 @@ var initCommand = new Command18("init").description("Configure grapity registry
1426
1743
  process.exit(1);
1427
1744
  }
1428
1745
  config.remote = {
1429
- url: options.url.replace(/\/$/, ""),
1430
- apiKey: options.apiKey
1746
+ url: options.url.replace(/\/$/, "")
1431
1747
  };
1748
+ if (keycloakAuth) {
1749
+ config.remote.auth = {
1750
+ mode: "keycloak",
1751
+ serverUrl: keycloakAuth.serverUrl,
1752
+ realm: keycloakAuth.realm,
1753
+ clientId: keycloakAuth.clientId,
1754
+ audience: keycloakAuth.audience,
1755
+ roleSource: keycloakAuth.roleSource
1756
+ };
1757
+ }
1432
1758
  }
1433
1759
  if (!fs8.existsSync(configDir)) {
1434
1760
  fs8.mkdirSync(configDir, { recursive: true });
@@ -1440,18 +1766,110 @@ var initCommand = new Command18("init").description("Configure grapity registry
1440
1766
  configPath,
1441
1767
  mode,
1442
1768
  port: config.local?.port,
1769
+ database: config.local?.database,
1443
1770
  dbPath: config.local?.sqlitePath,
1771
+ postgresUrl: config.local?.postgresUrl,
1444
1772
  url: config.remote?.url,
1445
- hasApiKey: !!config.remote?.apiKey
1773
+ authMode,
1774
+ keycloakServer: keycloakAuth?.serverUrl,
1775
+ keycloakRealm: keycloakAuth?.realm,
1776
+ keycloakClientId: config.local?.auth?.clientId ?? config.remote?.auth?.clientId
1446
1777
  })
1447
1778
  );
1448
1779
  });
1780
+ function parseKeycloakOptions(options, _mode) {
1781
+ if (!options.keycloakServer) {
1782
+ console.error(
1783
+ formatError(
1784
+ "missing flag",
1785
+ "--keycloak-server is required when auth mode is keycloak.",
1786
+ ["Example: --keycloak-server https://keycloak.example.com"]
1787
+ )
1788
+ );
1789
+ process.exit(1);
1790
+ }
1791
+ if (!options.keycloakRealm) {
1792
+ console.error(
1793
+ formatError(
1794
+ "missing flag",
1795
+ "--keycloak-realm is required when auth mode is keycloak.",
1796
+ ["Example: --keycloak-realm grapity"]
1797
+ )
1798
+ );
1799
+ process.exit(1);
1800
+ }
1801
+ const roleSource = options.keycloakRoleSource;
1802
+ if (roleSource && roleSource !== "scope" && roleSource !== "realm_access.roles") {
1803
+ console.error(formatError("invalid role source", "--keycloak-role-source must be one of: scope, realm_access.roles"));
1804
+ process.exit(1);
1805
+ }
1806
+ return {
1807
+ serverUrl: options.keycloakServer.replace(/\/$/, ""),
1808
+ realm: options.keycloakRealm,
1809
+ clientId: options.keycloakClientId,
1810
+ audience: options.keycloakAudience,
1811
+ roleSource
1812
+ };
1813
+ }
1449
1814
 
1450
- // src/cli/commands/serve.ts
1815
+ // src/cli/commands/auth.ts
1451
1816
  import { Command as Command19 } from "commander";
1817
+ var authCommand = new Command19("auth").description("Manage authentication for remote registry mode").addCommand(
1818
+ new Command19("status").description("Show current authentication status").action(async () => {
1819
+ const config = getConfig();
1820
+ const auth = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
1821
+ if (!auth) {
1822
+ console.log(formatAuthStatus({ mode: "none", configured: false }));
1823
+ return;
1824
+ }
1825
+ if (auth.mode !== "keycloak") {
1826
+ console.log(formatAuthStatus({ mode: auth.mode, configured: true }));
1827
+ return;
1828
+ }
1829
+ if (!auth.clientId) {
1830
+ console.error(
1831
+ formatError(
1832
+ "auth misconfigured",
1833
+ "Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
1834
+ )
1835
+ );
1836
+ process.exit(1);
1837
+ }
1838
+ try {
1839
+ const token = await getAccessToken(auth);
1840
+ const status = formatTokenStatus(token);
1841
+ console.log(
1842
+ formatAuthStatus({
1843
+ mode: auth.mode,
1844
+ configured: true,
1845
+ serverUrl: auth.serverUrl,
1846
+ realm: auth.realm,
1847
+ clientId: auth.clientId,
1848
+ audience: auth.audience,
1849
+ ...status
1850
+ })
1851
+ );
1852
+ } catch (err) {
1853
+ console.error(
1854
+ formatError(
1855
+ "auth failed",
1856
+ err instanceof Error ? err.message : "Unable to fetch access token"
1857
+ )
1858
+ );
1859
+ process.exit(1);
1860
+ }
1861
+ })
1862
+ ).addCommand(
1863
+ new Command19("clear").description("Clear the cached access token").action(() => {
1864
+ resetTokenCache();
1865
+ console.log(formatAuthStatus({ mode: "none", configured: false, cleared: true }));
1866
+ })
1867
+ );
1868
+
1869
+ // src/cli/commands/serve.ts
1870
+ import { Command as Command20 } from "commander";
1452
1871
  import os4 from "os";
1453
1872
  import path11 from "path";
1454
- import { readFileSync } from "fs";
1455
1873
 
1456
1874
  // src/registry/serve.ts
1457
1875
  import path9 from "path";
@@ -3090,9 +3508,9 @@ var RegistryService = class {
3090
3508
  return { spec, version: version2, compatReport, isNewSpec };
3091
3509
  }
3092
3510
  async listSpecs(filters) {
3093
- const specs2 = await this.store.listSpecs(filters);
3511
+ const specs3 = await this.store.listSpecs(filters);
3094
3512
  return Promise.all(
3095
- specs2.map(async (spec) => {
3513
+ specs3.map(async (spec) => {
3096
3514
  const latestVersion = await this.store.getLatestVersion(spec.name);
3097
3515
  return { ...spec, latestVersion: latestVersion ?? void 0 };
3098
3516
  })
@@ -3211,6 +3629,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
3211
3629
  }
3212
3630
  const store = c2.get("store");
3213
3631
  const service = new RegistryService(store);
3632
+ const actor = c2.get("actor") ?? body.pushedBy;
3214
3633
  try {
3215
3634
  const result = await service.pushSpec(body.content, body.name, {
3216
3635
  type: body.type,
@@ -3219,7 +3638,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
3219
3638
  sourceRepo: body.sourceRepo,
3220
3639
  tags: Array.isArray(body.tags) ? body.tags : void 0,
3221
3640
  gitRef: body.gitRef,
3222
- pushedBy: body.pushedBy,
3641
+ pushedBy: actor,
3223
3642
  prerelease: body.prerelease,
3224
3643
  force: body.force,
3225
3644
  reason: body.reason
@@ -3350,8 +3769,8 @@ var listRoute = new Hono3().get("/", async (c2) => {
3350
3769
  const type = c2.req.query("type");
3351
3770
  const owner = c2.req.query("owner");
3352
3771
  const tags = c2.req.query("tags")?.split(",");
3353
- const specs2 = await service.listSpecs({ type, owner, tags });
3354
- return c2.json({ data: specs2 });
3772
+ const specs3 = await service.listSpecs({ type, owner, tags });
3773
+ return c2.json({ data: specs3 });
3355
3774
  });
3356
3775
 
3357
3776
  // src/registry/routes/get-spec.ts
@@ -3384,7 +3803,8 @@ var deleteSpecRoute = new Hono5().delete(
3384
3803
  const name = c2.req.param("name");
3385
3804
  const store = c2.get("store");
3386
3805
  const service = new RegistryService(store);
3387
- const deleted = await service.deleteSpec(name);
3806
+ const actor = c2.get("actor");
3807
+ const deleted = await service.deleteSpec(name, actor);
3388
3808
  if (!deleted) {
3389
3809
  return c2.json({ error: "not_found", message: `Spec "${name}" not found`, statusCode: 404 }, 404);
3390
3810
  }
@@ -3985,8 +4405,7 @@ function switchTab(tab) {
3985
4405
  }
3986
4406
  var welcomeRoute = new Hono12().get("/", (c2) => {
3987
4407
  const config = c2.get("config");
3988
- const mode = config.database === "sqlite" ? "local" : "remote";
3989
- return c2.html(buildPage(config.port, mode));
4408
+ return c2.html(buildPage(config.port, "local"));
3990
4409
  });
3991
4410
 
3992
4411
  // src/registry/routes/push-gateway-config.ts
@@ -4126,6 +4545,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
4126
4545
  }
4127
4546
  const store = c2.get("store");
4128
4547
  const service = new GatewayService(store, store);
4548
+ const actor = c2.get("actor") ?? body.pushedBy;
4129
4549
  try {
4130
4550
  const result = await service.pushGatewayConfig({
4131
4551
  name: body.name,
@@ -4136,7 +4556,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
4136
4556
  environments: body.environments ?? {},
4137
4557
  callerIdentification: body.callerIdentification,
4138
4558
  content: body.content,
4139
- pushedBy: body.pushedBy
4559
+ pushedBy: actor
4140
4560
  });
4141
4561
  return c2.json({ data: result }, 201);
4142
4562
  } catch (err) {
@@ -4327,7 +4747,6 @@ var ingestGatewayLogRoute = new Hono18().post("/ingest/:provider/:environment",
4327
4747
  await service.ingestLog(provider, environment, payload);
4328
4748
  return c2.json({ status: "ok" }, 201);
4329
4749
  } catch (err) {
4330
- console.error("Gateway log ingest error:", err);
4331
4750
  return c2.json({
4332
4751
  error: "bad_request",
4333
4752
  message: err instanceof Error ? err.message : "Invalid log payload",
@@ -4403,6 +4822,14 @@ var gatewayLogStatsRoute = new Hono21().get("/stats", async (c2) => {
4403
4822
  });
4404
4823
 
4405
4824
  // src/registry/server.ts
4825
+ import { readFileSync } from "fs";
4826
+ import { fileURLToPath } from "url";
4827
+ import yaml10 from "js-yaml";
4828
+ function loadOpenApiSpec() {
4829
+ const path12 = fileURLToPath(new URL("../../openapi.yaml", import.meta.url));
4830
+ const content = readFileSync(path12, "utf-8");
4831
+ return yaml10.load(content);
4832
+ }
4406
4833
  function createApp(config, store) {
4407
4834
  const app = new Hono22();
4408
4835
  app.use("*", logger());
@@ -4413,6 +4840,21 @@ function createApp(config, store) {
4413
4840
  c2.set("config", config);
4414
4841
  await next();
4415
4842
  });
4843
+ const routeScopes = parseRouteScopes(loadOpenApiSpec());
4844
+ app.use("*", createAuthMiddleware(config, routeScopes));
4845
+ app.onError((err, c2) => {
4846
+ if (err instanceof AuthError) {
4847
+ return c2.json(
4848
+ { error: err.code, message: err.message, statusCode: err.statusCode },
4849
+ err.statusCode
4850
+ );
4851
+ }
4852
+ console.error("Unhandled error:", err);
4853
+ return c2.json(
4854
+ { error: "internal_error", message: "Internal server error", statusCode: 500 },
4855
+ 500
4856
+ );
4857
+ });
4416
4858
  app.route("/v1/specs", pushRoute);
4417
4859
  app.route("/v1/specs", validateRoute);
4418
4860
  app.route("/v1/specs", listRoute);
@@ -4441,8 +4883,7 @@ function createApp(config, store) {
4441
4883
  var defaultConfig = {
4442
4884
  port: 3750,
4443
4885
  database: "sqlite",
4444
- sqlitePath: void 0,
4445
- gracePeriodDays: 30
4886
+ auth: { mode: "none" }
4446
4887
  };
4447
4888
 
4448
4889
  // src/registry/storage/sqlite.ts
@@ -4948,137 +5389,829 @@ var SQLiteSpecStore = class {
4948
5389
  }
4949
5390
  };
4950
5391
 
4951
- // src/registry/serve.ts
4952
- async function startServer(userConfig) {
4953
- const config = { ...defaultConfig, ...userConfig };
4954
- if (!config.sqlitePath && config.database === "sqlite") {
4955
- const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
4956
- config.sqlitePath = path9.join(homeDir, ".grapity", "registry.db");
4957
- }
4958
- if (config.database === "sqlite" && config.sqlitePath) {
4959
- const dir = path9.dirname(config.sqlitePath);
4960
- if (!fs9.existsSync(dir)) {
4961
- fs9.mkdirSync(dir, { recursive: true });
4962
- }
4963
- }
4964
- const store = new SQLiteSpecStore(config.sqlitePath);
4965
- await store.migrate();
4966
- const app = createApp(config, store);
4967
- serve({
4968
- fetch: app.fetch,
4969
- port: config.port
4970
- });
4971
- return app;
4972
- }
4973
- if (process.argv[1] === new URL(import.meta.url).pathname) {
4974
- startServer();
4975
- }
4976
-
4977
- // src/hub/serve.ts
4978
- import { Hono as Hono23 } from "hono";
4979
- import { serveStatic } from "@hono/node-server/serve-static";
4980
- import { serve as serve2 } from "@hono/node-server";
4981
- import path10 from "path";
4982
- import fs10 from "fs";
5392
+ // src/registry/storage/postgresql.ts
5393
+ import { Pool } from "pg";
5394
+ import { drizzle as drizzle2 } from "drizzle-orm/node-postgres";
5395
+ import { migrate as migrate2 } from "drizzle-orm/node-postgres/migrator";
5396
+ import { eq as eq2, and as and2, desc as desc2, sql as sql2 } from "drizzle-orm";
4983
5397
 
4984
- // src/hub/paths.ts
4985
- var HUB_DIST_PATH = new URL(
4986
- "../../dist",
4987
- import.meta.url
4988
- ).pathname;
5398
+ // src/registry/storage/schema-pg.ts
5399
+ import { pgTable, text as text2, timestamp, boolean, jsonb, index as index2, integer as integer2 } from "drizzle-orm/pg-core";
5400
+ var specs2 = pgTable("specs", {
5401
+ id: text2("id").primaryKey(),
5402
+ name: text2("name").notNull().unique(),
5403
+ type: text2("type", { enum: ["openapi", "asyncapi"] }).notNull(),
5404
+ description: text2("description"),
5405
+ owner: text2("owner"),
5406
+ sourceRepo: text2("source_repo"),
5407
+ tags: jsonb("tags").$type().default([]),
5408
+ createdAt: timestamp("created_at").notNull(),
5409
+ updatedAt: timestamp("updated_at").notNull()
5410
+ });
5411
+ var specVersions2 = pgTable("spec_versions", {
5412
+ id: text2("id").primaryKey(),
5413
+ specId: text2("spec_id").notNull().references(() => specs2.id),
5414
+ semver: text2("semver").notNull(),
5415
+ content: text2("content").notNull(),
5416
+ checksum: text2("checksum").notNull(),
5417
+ gitRef: text2("git_ref"),
5418
+ pushedBy: text2("pushed_by"),
5419
+ compatibility: jsonb("compatibility").$type(),
5420
+ previousVersion: text2("previous_version"),
5421
+ forceReason: text2("force_reason"),
5422
+ isPrerelease: boolean("is_prerelease").notNull().default(false),
5423
+ createdAt: timestamp("created_at").notNull()
5424
+ }, (table) => [
5425
+ index2("idx_spec_versions_spec_id").on(table.specId),
5426
+ index2("idx_spec_versions_semver").on(table.specId, table.semver)
5427
+ ]);
5428
+ var auditLog2 = pgTable("audit_log", {
5429
+ id: text2("id").primaryKey(),
5430
+ action: text2("action", { enum: ["spec.push", "spec.push.force", "spec.delete"] }).notNull(),
5431
+ actor: text2("actor").notNull(),
5432
+ specName: text2("spec_name").notNull(),
5433
+ version: text2("version"),
5434
+ details: jsonb("details").$type(),
5435
+ createdAt: timestamp("created_at").notNull()
5436
+ }, (table) => [
5437
+ index2("idx_audit_log_spec_name").on(table.specName),
5438
+ index2("idx_audit_log_created_at").on(table.createdAt)
5439
+ ]);
5440
+ var gatewayConfigs2 = pgTable("gateway_configs", {
5441
+ id: text2("id").primaryKey(),
5442
+ name: text2("name").notNull().unique(),
5443
+ provider: text2("provider", { enum: ["kong"] }).notNull(),
5444
+ specName: text2("spec_name").notNull(),
5445
+ specSemver: text2("spec_semver").notNull(),
5446
+ createdAt: timestamp("created_at").notNull(),
5447
+ updatedAt: timestamp("updated_at").notNull()
5448
+ });
5449
+ var gatewayConfigVersions2 = pgTable("gateway_config_versions", {
5450
+ id: text2("id").primaryKey(),
5451
+ gatewayConfigId: text2("gateway_config_id").notNull().references(() => gatewayConfigs2.id),
5452
+ routes: jsonb("routes").$type().notNull(),
5453
+ environments: jsonb("environments").$type().notNull(),
5454
+ callerIdentification: jsonb("caller_identification").$type(),
5455
+ content: text2("content").notNull(),
5456
+ checksum: text2("checksum").notNull(),
5457
+ pushedBy: text2("pushed_by"),
5458
+ createdAt: timestamp("created_at").notNull()
5459
+ }, (table) => [
5460
+ index2("idx_gateway_config_versions_config_id").on(table.gatewayConfigId)
5461
+ ]);
5462
+ var httpLogs2 = pgTable("http_logs", {
5463
+ id: text2("id").primaryKey(),
5464
+ provider: text2("provider").notNull(),
5465
+ gatewayConfigName: text2("gateway_config_name").notNull(),
5466
+ environment: text2("environment").notNull(),
5467
+ method: text2("method").notNull(),
5468
+ path: text2("path").notNull(),
5469
+ routePath: text2("route_path"),
5470
+ status: integer2("status").notNull(),
5471
+ callerId: text2("caller_id"),
5472
+ callerSource: text2("caller_source"),
5473
+ callerConfidence: text2("caller_confidence").notNull(),
5474
+ occurredAt: timestamp("occurred_at").notNull(),
5475
+ createdAt: timestamp("created_at").notNull()
5476
+ }, (table) => [
5477
+ index2("idx_http_logs_config_env").on(table.gatewayConfigName, table.environment),
5478
+ index2("idx_http_logs_occurred_at").on(table.occurredAt),
5479
+ index2("idx_http_logs_caller").on(table.gatewayConfigName, table.environment, table.callerId)
5480
+ ]);
5481
+ var provisions2 = pgTable("provisions", {
5482
+ id: text2("id").primaryKey(),
5483
+ gatewayConfigName: text2("gateway_config_name").notNull(),
5484
+ gatewayConfigVersion: text2("gateway_config_version").notNull(),
5485
+ environment: text2("environment").notNull(),
5486
+ provider: text2("provider", { enum: ["kong"] }).notNull(),
5487
+ synced: boolean("synced").notNull().default(false),
5488
+ actor: text2("actor").notNull(),
5489
+ details: jsonb("details").$type(),
5490
+ createdAt: timestamp("created_at").notNull()
5491
+ }, (table) => [
5492
+ index2("idx_provisions_config_name").on(table.gatewayConfigName),
5493
+ index2("idx_provisions_created_at").on(table.createdAt)
5494
+ ]);
4989
5495
 
4990
- // src/hub/serve.ts
4991
- var DEFAULT_PORT = 3e3;
4992
- var DEFAULT_REGISTRY_URL = "http://localhost:3750";
4993
- async function startHubServer(userConfig) {
4994
- const config = {
4995
- port: userConfig?.port ?? DEFAULT_PORT,
4996
- registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL
4997
- };
4998
- const app = new Hono23();
4999
- app.use("/v1/*", async (c2) => {
5000
- const url = new URL(c2.req.url);
5001
- const targetUrl = config.registryUrl + url.pathname + url.search;
5002
- const response = await fetch(targetUrl, {
5003
- method: c2.req.method,
5004
- headers: c2.req.raw.headers,
5005
- body: c2.req.raw.body
5006
- });
5007
- return response;
5008
- });
5009
- app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
5010
- app.get("/*", async (c2) => {
5011
- const indexPath = path10.join(HUB_DIST_PATH, "index.html");
5012
- if (fs10.existsSync(indexPath)) {
5013
- return c2.html(fs10.readFileSync(indexPath, "utf-8"));
5496
+ // src/registry/storage/postgresql.ts
5497
+ import { v4 as uuid6 } from "uuid";
5498
+ var MIGRATIONS_FOLDER2 = PG_MIGRATIONS_FOLDER;
5499
+ var DatabaseConnectionError = class extends Error {
5500
+ constructor(message, postgresUrl, options) {
5501
+ super(message, options);
5502
+ this.postgresUrl = postgresUrl;
5503
+ this.name = "DatabaseConnectionError";
5504
+ }
5505
+ postgresUrl;
5506
+ };
5507
+ function isConnectionError(err) {
5508
+ if (typeof err !== "object" || err === null) return false;
5509
+ if ("code" in err) {
5510
+ const code = err.code;
5511
+ if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND") {
5512
+ return true;
5014
5513
  }
5015
- return c2.text(
5016
- "index.html not found. Build the project with 'bun run build' first.",
5017
- 404
5018
- );
5019
- });
5020
- serve2({
5021
- fetch: app.fetch,
5022
- port: config.port
5023
- });
5024
- }
5025
- if (process.argv[1] === new URL(import.meta.url).pathname) {
5026
- startHubServer();
5514
+ }
5515
+ if (err instanceof AggregateError) {
5516
+ if (err.errors.some(isConnectionError)) return true;
5517
+ }
5518
+ if ("cause" in err && err.cause) {
5519
+ return isConnectionError(err.cause);
5520
+ }
5521
+ return false;
5027
5522
  }
5028
-
5029
- // src/cli/commands/serve.ts
5030
- function getPackageVersion() {
5523
+ function maskPostgresPassword(url) {
5031
5524
  try {
5032
- const pkgPath = new URL("../../../../package.json", import.meta.url);
5033
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
5034
- return pkg.version;
5525
+ const parsed = new URL(url);
5526
+ if (parsed.password) parsed.password = "***";
5527
+ return parsed.toString();
5035
5528
  } catch {
5036
- return "unknown";
5529
+ return url;
5037
5530
  }
5038
5531
  }
5039
- function createServeCommand(_version) {
5040
- return new Command19("serve").description("Start the local grapity registry server").option("-p, --port <port>", "Port to listen on", "3750").option("--db <url>", "Database URL (sqlite path or postgresql URL)").option("--auth <mode>", "Auth mode: none, api-key, jwt", "none").option("--hub-port <port>", "Port for the developer portal (Hub)", "3000").option("--no-hub", "Skip starting the developer portal").action(async (options) => {
5041
- const port = parseInt(options.port, 10);
5042
- const hubPort = parseInt(options.hubPort, 10);
5043
- const startHub = options.hub !== false;
5044
- const db = options.db;
5045
- const auth = options.auth;
5046
- const isPostgres = db?.startsWith("postgresql://");
5047
- const dbMode = isPostgres ? "postgresql" : "sqlite";
5048
- const dbPath = isPostgres ? void 0 : db ?? path11.join(os4.homedir(), ".grapity", "registry.db");
5049
- const version2 = getPackageVersion();
5050
- console.log(formatHeader("grapity Registry", `v${version2}`));
5051
- console.log("");
5052
- console.log(formatServeConfig({ mode: dbMode, port, dbPath, auth }));
5053
- console.log("");
5054
- await startServer({
5055
- port,
5056
- database: dbMode,
5057
- sqlitePath: dbPath,
5058
- postgresUrl: isPostgres ? db : void 0,
5059
- auth: auth === "none" ? { mode: "none" } : { mode: auth }
5060
- });
5061
- console.log(formatReady(port));
5062
- if (startHub) {
5063
- console.log("");
5064
- console.log(formatHeader("grapity Hub", `v${version2}`));
5065
- console.log("");
5066
- console.log(
5067
- formatHubConfig({
5068
- port: hubPort,
5069
- registryUrl: `http://localhost:${port}`
5070
- })
5071
- );
5072
- console.log("");
5073
- await startHubServer({
5074
- port: hubPort,
5075
- registryUrl: `http://localhost:${port}`
5076
- });
5077
- console.log(formatHubReady(hubPort));
5078
- }
5079
- process.on("SIGINT", () => {
5532
+ var PostgreSQLSpecStore = class {
5533
+ db;
5534
+ pool;
5535
+ postgresUrl;
5536
+ constructor(postgresUrl) {
5537
+ this.postgresUrl = postgresUrl;
5538
+ this.pool = new Pool({ connectionString: postgresUrl });
5539
+ this.db = drizzle2(this.pool);
5540
+ }
5541
+ async migrate() {
5542
+ try {
5543
+ await migrate2(this.db, {
5544
+ migrationsFolder: MIGRATIONS_FOLDER2
5545
+ });
5546
+ } catch (err) {
5547
+ if (isConnectionError(err)) {
5548
+ throw new DatabaseConnectionError(
5549
+ `PostgreSQL is not reachable at ${maskPostgresPassword(this.postgresUrl)}`,
5550
+ this.postgresUrl,
5551
+ { cause: err }
5552
+ );
5553
+ }
5554
+ throw err;
5555
+ }
5556
+ }
5557
+ async end() {
5558
+ await this.pool.end();
5559
+ }
5560
+ async getSpec(name) {
5561
+ const rows = await this.db.select().from(specs2).where(eq2(specs2.name, name)).limit(1);
5562
+ if (rows.length === 0) return null;
5563
+ return this.mapSpecRow(rows[0]);
5564
+ }
5565
+ async getSpecVersion(name, semver) {
5566
+ const spec = await this.getSpec(name);
5567
+ if (!spec) return null;
5568
+ const rows = await this.db.select().from(specVersions2).where(and2(eq2(specVersions2.specId, spec.id), eq2(specVersions2.semver, semver))).limit(1);
5569
+ if (rows.length === 0) return null;
5570
+ return this.mapVersionRow(rows[0]);
5571
+ }
5572
+ async getLatestVersion(name) {
5573
+ const spec = await this.getSpec(name);
5574
+ if (!spec) return null;
5575
+ const rows = await this.db.select().from(specVersions2).where(eq2(specVersions2.specId, spec.id)).orderBy(desc2(specVersions2.createdAt), desc2(specVersions2.id)).limit(1);
5576
+ if (rows.length === 0) return null;
5577
+ return this.mapVersionRow(rows[0]);
5578
+ }
5579
+ async listSpecs(filters) {
5580
+ const conditions = [];
5581
+ if (filters?.type) conditions.push(eq2(specs2.type, filters.type));
5582
+ if (filters?.owner) conditions.push(eq2(specs2.owner, filters.owner));
5583
+ let rows = conditions.length > 0 ? await this.db.select().from(specs2).where(and2(...conditions)) : await this.db.select().from(specs2);
5584
+ if (filters?.tags && filters.tags.length > 0) {
5585
+ rows = rows.filter((row) => {
5586
+ const rowTags = row.tags ?? [];
5587
+ return filters.tags.every((tag) => rowTags.includes(tag));
5588
+ });
5589
+ }
5590
+ return rows.map((r) => this.mapSpecRow(r));
5591
+ }
5592
+ async listVersions(name, options) {
5593
+ const spec = await this.getSpec(name);
5594
+ if (!spec) return { versions: [], total: 0 };
5595
+ const limit = options?.limit ?? 10;
5596
+ const offset = options?.offset ?? 0;
5597
+ const [countRow] = await this.db.select({ count: sql2`count(*)` }).from(specVersions2).where(eq2(specVersions2.specId, spec.id));
5598
+ const total = Number(countRow?.count ?? 0);
5599
+ const rows = await this.db.select().from(specVersions2).where(eq2(specVersions2.specId, spec.id)).orderBy(desc2(specVersions2.createdAt), desc2(specVersions2.id)).limit(limit).offset(offset);
5600
+ return { versions: rows.map((r) => this.mapVersionRow(r)), total };
5601
+ }
5602
+ async pushSpecVersion(spec, version2) {
5603
+ const existingSpec = await this.getSpec(spec.name);
5604
+ if (!existingSpec) {
5605
+ await this.db.insert(specs2).values({
5606
+ id: spec.id,
5607
+ name: spec.name,
5608
+ type: spec.type,
5609
+ description: spec.description ?? null,
5610
+ owner: spec.owner ?? null,
5611
+ sourceRepo: spec.sourceRepo ?? null,
5612
+ tags: spec.tags ?? [],
5613
+ createdAt: spec.createdAt,
5614
+ updatedAt: spec.updatedAt
5615
+ });
5616
+ } else {
5617
+ await this.db.update(specs2).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq2(specs2.id, existingSpec.id));
5618
+ }
5619
+ const specId = existingSpec?.id ?? spec.id;
5620
+ await this.db.insert(specVersions2).values({
5621
+ id: version2.id,
5622
+ specId,
5623
+ semver: version2.semver,
5624
+ content: version2.content,
5625
+ checksum: version2.checksum,
5626
+ gitRef: version2.gitRef ?? null,
5627
+ pushedBy: version2.pushedBy ?? null,
5628
+ compatibility: version2.compatibility ?? null,
5629
+ previousVersion: version2.previousVersion ?? null,
5630
+ forceReason: version2.forceReason ?? null,
5631
+ isPrerelease: version2.isPrerelease,
5632
+ createdAt: version2.createdAt
5633
+ });
5634
+ return version2;
5635
+ }
5636
+ async deleteSpec(name) {
5637
+ const existingSpec = await this.getSpec(name);
5638
+ if (!existingSpec) return false;
5639
+ await this.db.delete(specVersions2).where(eq2(specVersions2.specId, existingSpec.id));
5640
+ await this.db.delete(specs2).where(eq2(specs2.id, existingSpec.id));
5641
+ return true;
5642
+ }
5643
+ async getCompatReport(name, semver) {
5644
+ const version2 = await this.getSpecVersion(name, semver);
5645
+ return version2?.compatibility ?? null;
5646
+ }
5647
+ async logAudit(action, actor, specName, version2, details) {
5648
+ await this.db.insert(auditLog2).values({
5649
+ id: uuid6(),
5650
+ action,
5651
+ actor,
5652
+ specName,
5653
+ version: version2 ?? null,
5654
+ details: details ?? null,
5655
+ createdAt: /* @__PURE__ */ new Date()
5656
+ });
5657
+ }
5658
+ mapSpecRow(row) {
5659
+ return {
5660
+ id: row.id,
5661
+ name: row.name,
5662
+ type: row.type,
5663
+ description: row.description ?? void 0,
5664
+ owner: row.owner ?? void 0,
5665
+ sourceRepo: row.sourceRepo ?? void 0,
5666
+ tags: row.tags ?? [],
5667
+ createdAt: row.createdAt,
5668
+ updatedAt: row.updatedAt
5669
+ };
5670
+ }
5671
+ mapVersionRow(row) {
5672
+ return {
5673
+ id: row.id,
5674
+ specId: row.specId,
5675
+ semver: row.semver,
5676
+ content: row.content,
5677
+ checksum: row.checksum,
5678
+ gitRef: row.gitRef ?? void 0,
5679
+ pushedBy: row.pushedBy ?? void 0,
5680
+ compatibility: row.compatibility ?? void 0,
5681
+ previousVersion: row.previousVersion ?? void 0,
5682
+ forceReason: row.forceReason ?? void 0,
5683
+ isPrerelease: row.isPrerelease,
5684
+ createdAt: row.createdAt
5685
+ };
5686
+ }
5687
+ // GatewayConfigStore implementation
5688
+ async getGatewayConfig(name) {
5689
+ const rows = await this.db.select().from(gatewayConfigs2).where(eq2(gatewayConfigs2.name, name)).limit(1);
5690
+ if (rows.length === 0) return null;
5691
+ return this.mapGatewayConfigRow(rows[0]);
5692
+ }
5693
+ async listGatewayConfigs() {
5694
+ const rows = await this.db.select().from(gatewayConfigs2);
5695
+ return rows.map((r) => this.mapGatewayConfigRow(r));
5696
+ }
5697
+ async getGatewayConfigVersion(name, versionId) {
5698
+ const config = await this.getGatewayConfig(name);
5699
+ if (!config) return null;
5700
+ const rows = await this.db.select().from(gatewayConfigVersions2).where(and2(eq2(gatewayConfigVersions2.gatewayConfigId, config.id), eq2(gatewayConfigVersions2.id, versionId))).limit(1);
5701
+ if (rows.length === 0) return null;
5702
+ return this.mapGatewayConfigVersionRow(rows[0]);
5703
+ }
5704
+ async getLatestGatewayConfigVersion(name) {
5705
+ const config = await this.getGatewayConfig(name);
5706
+ if (!config) return null;
5707
+ const rows = await this.db.select().from(gatewayConfigVersions2).where(eq2(gatewayConfigVersions2.gatewayConfigId, config.id)).orderBy(desc2(gatewayConfigVersions2.createdAt), desc2(gatewayConfigVersions2.id)).limit(1);
5708
+ if (rows.length === 0) return null;
5709
+ return this.mapGatewayConfigVersionRow(rows[0]);
5710
+ }
5711
+ async listGatewayConfigVersions(name) {
5712
+ const config = await this.getGatewayConfig(name);
5713
+ if (!config) return [];
5714
+ const rows = await this.db.select().from(gatewayConfigVersions2).where(eq2(gatewayConfigVersions2.gatewayConfigId, config.id)).orderBy(desc2(gatewayConfigVersions2.createdAt), desc2(gatewayConfigVersions2.id)).limit(5);
5715
+ return rows.map((r) => this.mapGatewayConfigVersionRow(r));
5716
+ }
5717
+ async pushGatewayConfigVersion(config, version2) {
5718
+ const existingConfig = await this.getGatewayConfig(config.name);
5719
+ if (!existingConfig) {
5720
+ await this.db.insert(gatewayConfigs2).values({
5721
+ id: config.id,
5722
+ name: config.name,
5723
+ provider: config.provider,
5724
+ specName: config.specName,
5725
+ specSemver: config.specSemver,
5726
+ createdAt: config.createdAt,
5727
+ updatedAt: config.updatedAt
5728
+ });
5729
+ } else {
5730
+ await this.db.update(gatewayConfigs2).set({ updatedAt: /* @__PURE__ */ new Date(), specSemver: config.specSemver }).where(eq2(gatewayConfigs2.id, existingConfig.id));
5731
+ }
5732
+ const configId = existingConfig?.id ?? config.id;
5733
+ await this.db.insert(gatewayConfigVersions2).values({
5734
+ id: version2.id,
5735
+ gatewayConfigId: configId,
5736
+ routes: version2.routes,
5737
+ environments: version2.environments,
5738
+ callerIdentification: version2.callerIdentification ?? null,
5739
+ content: version2.content,
5740
+ checksum: version2.checksum,
5741
+ pushedBy: version2.pushedBy ?? null,
5742
+ createdAt: version2.createdAt
5743
+ });
5744
+ const versions = await this.db.select().from(gatewayConfigVersions2).where(eq2(gatewayConfigVersions2.gatewayConfigId, configId)).orderBy(desc2(gatewayConfigVersions2.createdAt), desc2(gatewayConfigVersions2.id));
5745
+ if (versions.length > 5) {
5746
+ const toDelete = versions.slice(5);
5747
+ for (const v of toDelete) {
5748
+ await this.db.delete(gatewayConfigVersions2).where(eq2(gatewayConfigVersions2.id, v.id));
5749
+ }
5750
+ }
5751
+ return version2;
5752
+ }
5753
+ async recordProvision(provision) {
5754
+ await this.db.insert(provisions2).values({
5755
+ id: provision.id,
5756
+ gatewayConfigName: provision.gatewayConfigName,
5757
+ gatewayConfigVersion: provision.gatewayConfigVersion,
5758
+ environment: provision.environment,
5759
+ provider: provision.provider,
5760
+ synced: provision.synced,
5761
+ actor: provision.actor,
5762
+ details: provision.details ?? null,
5763
+ createdAt: provision.createdAt
5764
+ });
5765
+ }
5766
+ async listProvisions(gatewayConfigName) {
5767
+ const rows = gatewayConfigName ? await this.db.select().from(provisions2).where(eq2(provisions2.gatewayConfigName, gatewayConfigName)).orderBy(desc2(provisions2.createdAt)) : await this.db.select().from(provisions2).orderBy(desc2(provisions2.createdAt));
5768
+ return rows.map((r) => ({
5769
+ id: r.id,
5770
+ gatewayConfigName: r.gatewayConfigName,
5771
+ gatewayConfigVersion: r.gatewayConfigVersion,
5772
+ environment: r.environment,
5773
+ provider: r.provider,
5774
+ synced: r.synced,
5775
+ actor: r.actor,
5776
+ details: r.details ?? void 0,
5777
+ createdAt: r.createdAt
5778
+ }));
5779
+ }
5780
+ mapGatewayConfigRow(row) {
5781
+ return {
5782
+ id: row.id,
5783
+ name: row.name,
5784
+ provider: row.provider,
5785
+ specName: row.specName,
5786
+ specSemver: row.specSemver,
5787
+ createdAt: row.createdAt,
5788
+ updatedAt: row.updatedAt
5789
+ };
5790
+ }
5791
+ mapGatewayConfigVersionRow(row) {
5792
+ return {
5793
+ id: row.id,
5794
+ gatewayConfigId: row.gatewayConfigId,
5795
+ routes: row.routes,
5796
+ environments: row.environments,
5797
+ callerIdentification: row.callerIdentification ?? void 0,
5798
+ content: row.content,
5799
+ checksum: row.checksum,
5800
+ pushedBy: row.pushedBy ?? void 0,
5801
+ createdAt: row.createdAt
5802
+ };
5803
+ }
5804
+ async recordGatewayLog(log) {
5805
+ await this.db.insert(httpLogs2).values({
5806
+ id: log.id,
5807
+ provider: log.provider,
5808
+ gatewayConfigName: log.gatewayConfigName,
5809
+ environment: log.environment,
5810
+ method: log.method,
5811
+ path: log.path,
5812
+ routePath: log.routePath ?? null,
5813
+ status: log.status,
5814
+ callerId: log.callerId ?? null,
5815
+ callerSource: log.callerSource ?? null,
5816
+ callerConfidence: log.callerConfidence,
5817
+ occurredAt: log.occurredAt,
5818
+ createdAt: log.createdAt
5819
+ });
5820
+ }
5821
+ async listGatewayLogs(filters) {
5822
+ const limit = filters.limit ?? 50;
5823
+ const offset = filters.offset ?? 0;
5824
+ let query = this.db.select().from(httpLogs2);
5825
+ const conditions = [];
5826
+ if (filters.gatewayConfigName) {
5827
+ conditions.push(eq2(httpLogs2.gatewayConfigName, filters.gatewayConfigName));
5828
+ }
5829
+ if (filters.environment) {
5830
+ conditions.push(eq2(httpLogs2.environment, filters.environment));
5831
+ }
5832
+ if (filters.path) {
5833
+ conditions.push(eq2(httpLogs2.path, filters.path));
5834
+ }
5835
+ if (filters.method) {
5836
+ conditions.push(eq2(httpLogs2.method, filters.method));
5837
+ }
5838
+ if (filters.status !== void 0) {
5839
+ conditions.push(eq2(httpLogs2.status, filters.status));
5840
+ }
5841
+ if (filters.from) {
5842
+ conditions.push(sql2`${httpLogs2.occurredAt} >= ${filters.from}`);
5843
+ }
5844
+ if (filters.to) {
5845
+ conditions.push(sql2`${httpLogs2.occurredAt} <= ${filters.to}`);
5846
+ }
5847
+ if (conditions.length > 0) {
5848
+ query = query.where(and2(...conditions));
5849
+ }
5850
+ const countResult = await this.db.select({ count: sql2`count(*)` }).from(httpLogs2).where(conditions.length > 0 ? and2(...conditions) : void 0);
5851
+ const total = countResult[0]?.count ?? 0;
5852
+ const rows = await query.orderBy(desc2(httpLogs2.occurredAt)).limit(limit).offset(offset);
5853
+ return {
5854
+ logs: rows.map((r) => ({
5855
+ id: r.id,
5856
+ provider: r.provider,
5857
+ gatewayConfigName: r.gatewayConfigName,
5858
+ environment: r.environment,
5859
+ method: r.method,
5860
+ path: r.path,
5861
+ routePath: r.routePath ?? void 0,
5862
+ status: r.status,
5863
+ callerId: r.callerId ?? void 0,
5864
+ callerSource: r.callerSource ?? void 0,
5865
+ callerConfidence: r.callerConfidence,
5866
+ occurredAt: r.occurredAt,
5867
+ createdAt: r.createdAt
5868
+ })),
5869
+ total
5870
+ };
5871
+ }
5872
+ async getGatewayLog(id) {
5873
+ const rows = await this.db.select().from(httpLogs2).where(eq2(httpLogs2.id, id)).limit(1);
5874
+ if (rows.length === 0) return null;
5875
+ const r = rows[0];
5876
+ return {
5877
+ id: r.id,
5878
+ provider: r.provider,
5879
+ gatewayConfigName: r.gatewayConfigName,
5880
+ environment: r.environment,
5881
+ method: r.method,
5882
+ path: r.path,
5883
+ routePath: r.routePath ?? void 0,
5884
+ status: r.status,
5885
+ callerId: r.callerId ?? void 0,
5886
+ callerSource: r.callerSource ?? void 0,
5887
+ callerConfidence: r.callerConfidence,
5888
+ occurredAt: r.occurredAt,
5889
+ createdAt: r.createdAt
5890
+ };
5891
+ }
5892
+ async getGatewayLogStats(_filters) {
5893
+ const conditions = [];
5894
+ if (_filters.gatewayConfigName) {
5895
+ conditions.push(eq2(httpLogs2.gatewayConfigName, _filters.gatewayConfigName));
5896
+ }
5897
+ if (_filters.environment) {
5898
+ conditions.push(eq2(httpLogs2.environment, _filters.environment));
5899
+ }
5900
+ if (_filters.from) {
5901
+ conditions.push(sql2`${httpLogs2.occurredAt} >= ${_filters.from}`);
5902
+ }
5903
+ if (_filters.to) {
5904
+ conditions.push(sql2`${httpLogs2.occurredAt} <= ${_filters.to}`);
5905
+ }
5906
+ const whereClause = conditions.length > 0 ? and2(...conditions) : void 0;
5907
+ const rows = await this.db.select({
5908
+ gatewayConfigName: httpLogs2.gatewayConfigName,
5909
+ environment: httpLogs2.environment,
5910
+ method: httpLogs2.method,
5911
+ routePath: httpLogs2.routePath,
5912
+ lastSeenAt: sql2`max(${httpLogs2.occurredAt})`,
5913
+ totalCalls: sql2`count(*)`,
5914
+ uniqueCallerIds: sql2`count(distinct ${httpLogs2.callerId})`
5915
+ }).from(httpLogs2).where(whereClause).groupBy(httpLogs2.gatewayConfigName, httpLogs2.environment, httpLogs2.method, httpLogs2.routePath);
5916
+ return rows.map((r) => ({
5917
+ gatewayConfigName: r.gatewayConfigName,
5918
+ environment: r.environment,
5919
+ method: r.method,
5920
+ routePath: r.routePath ?? "/",
5921
+ lastSeenAt: new Date(r.lastSeenAt),
5922
+ totalCalls: r.totalCalls,
5923
+ uniqueCallerIds: r.uniqueCallerIds
5924
+ }));
5925
+ }
5926
+ async deleteGatewayLogsOlderThan(days) {
5927
+ const cutoff = /* @__PURE__ */ new Date();
5928
+ cutoff.setDate(cutoff.getDate() - days);
5929
+ await this.db.delete(httpLogs2).where(sql2`${httpLogs2.occurredAt} < ${cutoff}`);
5930
+ }
5931
+ };
5932
+
5933
+ // src/registry/serve.ts
5934
+ async function startServer(userConfig) {
5935
+ const config = { ...defaultConfig, ...userConfig };
5936
+ let store;
5937
+ if (config.database === "postgresql") {
5938
+ if (!config.postgresUrl) {
5939
+ throw new Error("PostgreSQL database requested but no postgresUrl provided.");
5940
+ }
5941
+ store = new PostgreSQLSpecStore(config.postgresUrl);
5942
+ } else {
5943
+ const sqlitePath = config.sqlitePath ?? path9.join(
5944
+ process.env.HOME || process.env.USERPROFILE || ".",
5945
+ ".grapity",
5946
+ "registry.db"
5947
+ );
5948
+ const dir = path9.dirname(sqlitePath);
5949
+ if (!fs9.existsSync(dir)) {
5950
+ fs9.mkdirSync(dir, { recursive: true });
5951
+ }
5952
+ store = new SQLiteSpecStore(sqlitePath);
5953
+ }
5954
+ await store.migrate();
5955
+ const app = createApp(config, store);
5956
+ const server = serve({
5957
+ fetch: app.fetch,
5958
+ port: config.port
5959
+ });
5960
+ return { app, store, server };
5961
+ }
5962
+ if (process.argv[1] === new URL(import.meta.url).pathname) {
5963
+ startServer();
5964
+ }
5965
+
5966
+ // src/hub/serve.ts
5967
+ import { Hono as Hono23 } from "hono";
5968
+ import { serveStatic } from "@hono/node-server/serve-static";
5969
+ import { serve as serve2 } from "@hono/node-server";
5970
+ import path10 from "path";
5971
+ import fs10 from "fs";
5972
+
5973
+ // src/hub/paths.ts
5974
+ var HUB_DIST_PATH = new URL(
5975
+ "../../dist",
5976
+ import.meta.url
5977
+ ).pathname;
5978
+
5979
+ // src/hub/serve.ts
5980
+ var DEFAULT_PORT = 3e3;
5981
+ var DEFAULT_REGISTRY_URL = "http://localhost:3750";
5982
+ async function startHubServer(userConfig) {
5983
+ const config = {
5984
+ port: userConfig?.port ?? DEFAULT_PORT,
5985
+ registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL,
5986
+ auth: userConfig?.auth
5987
+ };
5988
+ const app = new Hono23();
5989
+ app.get("/config.js", (c2) => {
5990
+ const clientConfig = {
5991
+ registryUrl: config.registryUrl,
5992
+ auth: config.auth ? {
5993
+ mode: config.auth.mode,
5994
+ serverUrl: config.auth.serverUrl,
5995
+ realm: config.auth.realm,
5996
+ clientId: config.auth.clientId,
5997
+ audience: config.auth.audience
5998
+ } : void 0
5999
+ };
6000
+ c2.header("Content-Type", "application/javascript");
6001
+ return c2.body(
6002
+ `window.__GRAPITY_CONFIG__ = ${JSON.stringify(clientConfig)};`
6003
+ );
6004
+ });
6005
+ app.use("/v1/*", async (c2) => {
6006
+ const url = new URL(c2.req.url);
6007
+ const targetUrl = config.registryUrl + url.pathname + url.search;
6008
+ const headers = new Headers(c2.req.raw.headers);
6009
+ headers.delete("host");
6010
+ const response = await fetch(targetUrl, {
6011
+ method: c2.req.method,
6012
+ headers,
6013
+ body: c2.req.raw.body
6014
+ });
6015
+ return response;
6016
+ });
6017
+ app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
6018
+ app.get("/*", async (c2) => {
6019
+ const indexPath = path10.join(HUB_DIST_PATH, "index.html");
6020
+ if (!fs10.existsSync(indexPath)) {
6021
+ return c2.text(
6022
+ "index.html not found. Build the project with 'bun run build' first.",
6023
+ 404
6024
+ );
6025
+ }
6026
+ const html = fs10.readFileSync(indexPath, "utf-8");
6027
+ const configScript = `
6028
+ <script src="/config.js"></script>
6029
+ `;
6030
+ const injected = html.replace(
6031
+ "</head>",
6032
+ `${configScript}</head>`
6033
+ );
6034
+ return c2.html(injected);
6035
+ });
6036
+ serve2({
6037
+ fetch: app.fetch,
6038
+ port: config.port
6039
+ });
6040
+ }
6041
+ if (process.argv[1] === new URL(import.meta.url).pathname) {
6042
+ startHubServer();
6043
+ }
6044
+
6045
+ // src/cli/commands/serve.ts
6046
+ function resolveServerConfig(cliOptions, cliConfig) {
6047
+ if (cliConfig.mode === "remote") {
6048
+ throw new Error(
6049
+ `grapity serve is for local mode only. Your config is set to remote (${cliConfig.remote?.url ?? "no URL"}). Use grapity serve on the machine running the local registry.`
6050
+ );
6051
+ }
6052
+ const envUrl = process.env.GRAPITY_DATABASE_URL;
6053
+ const local = cliConfig.local;
6054
+ const dbValue = envUrl ?? local?.postgresUrl;
6055
+ const base = { port: cliOptions.port };
6056
+ if (dbValue && isPostgresqlUrl(dbValue)) {
6057
+ base.database = "postgresql";
6058
+ base.postgresUrl = dbValue;
6059
+ } else {
6060
+ base.database = "sqlite";
6061
+ base.sqlitePath = dbValue ?? local?.sqlitePath ?? path11.join(os4.homedir(), ".grapity", "registry.db");
6062
+ }
6063
+ if (cliOptions.noAuth) {
6064
+ base.auth = { mode: "none" };
6065
+ } else {
6066
+ base.auth = resolveAuthConfig(local?.auth);
6067
+ }
6068
+ return base;
6069
+ }
6070
+ function resolveAuthConfig(authConfig) {
6071
+ if (!authConfig || authConfig.mode === "none") {
6072
+ throw new Error(
6073
+ "Authentication is required by default. Configure it with: grapity init --local --auth keycloak ...\nOr run with: grapity serve --no-auth"
6074
+ );
6075
+ }
6076
+ if (!authConfig.serverUrl) {
6077
+ throw new Error(
6078
+ "Keycloak auth is configured but serverUrl is missing. Run grapity init --local --auth keycloak --keycloak-server <url> ..."
6079
+ );
6080
+ }
6081
+ if (!authConfig.realm) {
6082
+ throw new Error(
6083
+ "Keycloak auth is configured but realm is missing. Run grapity init --local --auth keycloak --keycloak-realm <realm> ..."
6084
+ );
6085
+ }
6086
+ return {
6087
+ mode: "keycloak",
6088
+ serverUrl: authConfig.serverUrl.replace(/\/$/, ""),
6089
+ realm: authConfig.realm,
6090
+ audience: authConfig.audience,
6091
+ roleSource: authConfig.roleSource
6092
+ };
6093
+ }
6094
+ async function verifyKeycloakReachable(serverUrl, realm) {
6095
+ const url = `${serverUrl}/realms/${realm}/.well-known/openid-configuration`;
6096
+ try {
6097
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
6098
+ if (!res.ok) {
6099
+ throw new Error(`Keycloak returned ${res.status}`);
6100
+ }
6101
+ } catch (err) {
6102
+ const message = err instanceof Error ? err.message : String(err);
6103
+ throw new Error(
6104
+ `Keycloak is not reachable at ${url}: ${message}
6105
+ See https://grapity.dev/docs/cli-reference/init#local-mode-with-keycloak to set up a local Keycloak server.
6106
+ Or run with: grapity serve --no-auth`
6107
+ );
6108
+ }
6109
+ }
6110
+ function formatDatabaseConnectionError(err) {
6111
+ return formatError(
6112
+ "PostgreSQL is not reachable",
6113
+ err.message,
6114
+ [
6115
+ "Make sure PostgreSQL is running and accessible.",
6116
+ "Check the database URL in ~/.grapity/config.yaml or GRAPITY_DATABASE_URL."
6117
+ ]
6118
+ );
6119
+ }
6120
+ function createServeCommand(version2) {
6121
+ return new Command20("serve").description("Start the local grapity registry server").option("-p, --port <port>", "Port to listen on", "3750").option("--hub-port <port>", "Port for the developer portal (Hub)", "3000").option("--no-hub", "Skip starting the developer portal").option("--no-auth", "Start without authentication").action(async (options) => {
6122
+ const port = parseInt(options.port, 10);
6123
+ const hubPort = parseInt(options.hubPort, 10);
6124
+ const startHub = options.hub !== false;
6125
+ const noAuth = options.auth === false;
6126
+ if (!configExists()) {
6127
+ console.error(
6128
+ formatError(
6129
+ "not initialized",
6130
+ "Run grapity init first to configure the local registry.",
6131
+ ["Example: grapity init --local"]
6132
+ )
6133
+ );
6134
+ process.exit(1);
6135
+ }
6136
+ const cliConfig = getConfig();
6137
+ let serverConfig;
6138
+ try {
6139
+ serverConfig = resolveServerConfig({ port, noAuth }, cliConfig);
6140
+ } catch (err) {
6141
+ console.error(formatError("invalid config", err.message));
6142
+ process.exit(1);
6143
+ }
6144
+ if (serverConfig.auth?.mode === "keycloak" && !noAuth) {
6145
+ try {
6146
+ await verifyKeycloakReachable(
6147
+ serverConfig.auth.serverUrl,
6148
+ serverConfig.auth.realm
6149
+ );
6150
+ } catch (err) {
6151
+ console.error(formatError("keycloak unreachable", err.message));
6152
+ process.exit(1);
6153
+ }
6154
+ }
6155
+ console.log(formatHeader("grapity", `v${version2}`));
6156
+ console.log("");
6157
+ console.log(formatHeader("grapity Registry"));
6158
+ console.log("");
6159
+ console.log(
6160
+ formatServeConfig({
6161
+ port,
6162
+ database: serverConfig.database,
6163
+ dbPath: serverConfig.sqlitePath,
6164
+ postgresUrl: serverConfig.postgresUrl,
6165
+ authMode: serverConfig.auth?.mode,
6166
+ keycloakServer: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.serverUrl : void 0,
6167
+ keycloakRealm: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.realm : void 0,
6168
+ keycloakAudience: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.audience : void 0
6169
+ })
6170
+ );
6171
+ console.log("");
6172
+ let store;
6173
+ try {
6174
+ const started = await startServer(serverConfig);
6175
+ store = started.store;
6176
+ } catch (err) {
6177
+ if (err instanceof DatabaseConnectionError) {
6178
+ console.error(formatDatabaseConnectionError(err));
6179
+ } else {
6180
+ console.error(formatError("failed to start server", err.message));
6181
+ }
6182
+ process.exit(1);
6183
+ }
6184
+ console.log(formatReady(port));
6185
+ if (startHub) {
6186
+ console.log("");
6187
+ console.log(formatHeader("grapity Hub"));
6188
+ console.log("");
6189
+ console.log(
6190
+ formatHubConfig({
6191
+ port: hubPort,
6192
+ registryUrl: `http://localhost:${port}`
6193
+ })
6194
+ );
6195
+ console.log("");
6196
+ await startHubServer({
6197
+ port: hubPort,
6198
+ registryUrl: `http://localhost:${port}`,
6199
+ auth: serverConfig.auth?.mode === "keycloak" ? {
6200
+ mode: "keycloak",
6201
+ serverUrl: serverConfig.auth.serverUrl,
6202
+ realm: serverConfig.auth.realm,
6203
+ clientId: "grapity-hub",
6204
+ audience: serverConfig.auth.audience
6205
+ } : void 0
6206
+ });
6207
+ console.log(formatHubReady(hubPort));
6208
+ }
6209
+ process.on("SIGINT", async () => {
5080
6210
  console.log("");
5081
6211
  console.log(formatShutdown());
6212
+ if ("end" in store && typeof store.end === "function") {
6213
+ await store.end();
6214
+ }
5082
6215
  process.exit(0);
5083
6216
  });
5084
6217
  });
@@ -5088,7 +6221,7 @@ function createServeCommand(_version) {
5088
6221
  import { createRequire } from "module";
5089
6222
  var require2 = createRequire(import.meta.url);
5090
6223
  var { version } = require2("../../package.json");
5091
- var program = new Command20();
6224
+ var program = new Command21();
5092
6225
  program.name("grapity").description("grapity - API spec registry and compatibility guardian").version(version).addHelpText(
5093
6226
  "after",
5094
6227
  "\nDocumentation: https://grapity.dev/docs/getting-started/quickstart"
@@ -5096,5 +6229,6 @@ program.name("grapity").description("grapity - API spec registry and compatibili
5096
6229
  program.addCommand(registryCommand);
5097
6230
  program.addCommand(gatewayCommand);
5098
6231
  program.addCommand(initCommand);
6232
+ program.addCommand(authCommand);
5099
6233
  program.addCommand(createServeCommand(version));
5100
6234
  program.parse();